Compare commits

...
2 Commits
Author SHA1 Message Date
kstyagi 781ee753ec Refactor code structure for improved readability and maintainability 2026-09-20 09:08:45 +05:30
kstyagi b3b1b527e1 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.
2026-09-19 10:50:29 +05:30
18 changed files with 3287 additions and 69 deletions
+43 -15
View File
@@ -13,8 +13,10 @@ general-purpose Python library or a multi-host control plane.
UVM installs host/guest assets, provisions private disks, and manages named
VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk
start/stop/restart, deletion, fixed-target bulk operations, inspection, SSH,
logs, doctor, and explicit port edits/sync. The optional API exposes only the
existing health/install/create/list/inspect/stop/delete operations.
logs, doctor, and explicit port edits/sync. The optional API and local dashboard
expose health, installation, VM lifecycle controls, and managed port-policy
controls, host diagnostics, bounded VM-log tails, and fixed-target bulk
operations. Interactive SSH remains CLI-only.
There is no autostart or background reconciler, multi-host control plane,
tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port
@@ -80,7 +82,10 @@ Packet integration tests must remain inside disposable isolated namespaces.
│ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent
│ ├── api_models.py # Pydantic HTTP request/response models
│ ├── server.py # FastAPI factory and Uvicorn launcher
│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS
│ ├── routers/
│ │ ├── dashboard.py # Dashboard document route
│ │ ├── diagnostics.py # Host diagnostic endpoint
│ │ ├── dependencies.py # API application/token dependencies
│ │ ├── health.py # Liveness endpoint
│ │ ├── installation.py # Installation endpoint
@@ -158,9 +163,12 @@ checks, not per-VM repair or guest application probes.
### HTTP Path
Routers validate the token and Pydantic request, invoke application services,
and render whitelisted response models. The CLI expansion does not imply HTTP
endpoint parity: start/restart, port edits/sync, bulk operations, logs, doctor,
and interactive SSH have no HTTP endpoints.
and render whitelisted response models. Lifecycle, managed port-policy,
diagnostics, and bounded log-tail operations have explicit HTTP routes for the
dashboard. Bulk operations have a dedicated fixed-target HTTP route; interactive
SSH has no HTTP endpoint. The public dashboard shell does not bypass token
validation: it stores a supplied token only for the active browser session and
sends it with management requests.
Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The
`create_api()` factory requires an explicit host specifically to prevent it
@@ -645,13 +653,22 @@ or secret manager when that matters.
| Method | Route | Router | Notes |
|---|---|---|---|
| `GET` | `/` | `server.py` | Redirect to `/dashboard` |
| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell |
| `GET` | `/health` | `health.py` | Unauthenticated liveness response |
| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks |
| `POST` | `/install` | `installation.py` | `{ "force": false }` body |
| `GET` | `/vms` | `vms.py` | List persisted VM records and observed status |
| `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` |
| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action |
| `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop while retaining private disk |
| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files and state |
| `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk |
| `POST` | `/vms/{identifier}/restart` | `vms.py` | Restart; optional `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop; optional `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; optional `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | `vms.py` | Exact ID only; body has `mode` and `ports` |
| `POST` | `/vms/{vm_id}/ports/sync` | `vms.py` | Exact ID only; reapply managed policy |
| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail |
All management routes use `get_authorized_application()` and require:
@@ -671,14 +688,24 @@ unmanaged VM from an omitted policy. Names share CLI validation.
VM responses include `name`, `ports`, `policy_status`, and `operation` alongside
resource/status fields and username. The schema excludes password and
disk/socket/config/log path fields; error messages may still contain paths.
`ports: null` in a legacy response still means unmanaged. The existing
stop/delete routes use service defaults; no timeout/dry-run/bulk HTTP schemas
were added. Typed not-found/conflict errors map to 404/409.
`ports: null` in a legacy response still means unmanaged. Start/restart/stop
and port-policy mutations report the resulting policy status. Restart, stop,
and delete accept an optional finite non-negative `timeout` query parameter;
there is no dry-run HTTP schema. Port mutation bodies use
`{"mode":"set"|"open"|"close", "ports":[...]}` and always resolve the
target by exact ID to prevent name/IP reuse. A legacy policy must be set while
stopped before it can be started or synced. Typed not-found/conflict errors map
to 404/409.
There are **no new start/restart, ports, or bulk HTTP endpoints**. The API also
does not expose logs, doctor, interactive SSH, raw Firecracker calls, arbitrary
command execution, or host path access. Public `/docs`, `/redoc`, and
`/openapi.json` are informational, not privileged management routes.
Log reads accept a `tail` query parameter from 0 through 1000 and never follow
the file. `LifecycleService.read_log_tail()` reads at most the final 256 KiB and
marks a response truncated when an oversized line or that bound prevents a full
tail. Bulk bodies use an action plus either explicit identifiers or `all_vms`;
the router resolves them before `run_many()` executes the fixed IDs under one
operation lock. The API does not expose interactive SSH, raw Firecracker calls,
arbitrary command execution, or host path access. Public `/`, `/dashboard`,
`/docs`, `/redoc`, and `/openapi.json` do not perform privileged management
requests.
## 14. Configuration Reference
@@ -810,7 +837,8 @@ on an isolated Linux host:
`get_authorized_application()`, and register new routers in `create_api()`.
Add API tests and update all route tables. New CLI commands do not implicitly
authorize new HTTP endpoints; `/health` remains the only unauthenticated
operational route.
operational API route, while `/` and `/dashboard` only serve the public
dashboard shell.
- Host primitives: use the specific network/firewall/install/process module
and `CommandRunner` for external commands. Define ownership, idempotence,
and compensation before invoking a primitive from lifecycle code. Use fake
+42 -11
View File
@@ -217,35 +217,63 @@ 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 dashboard includes a fleet overview, searchable machine collection, private
network and port-policy view, host diagnostics, and machine provisioning. Open a
machine to manage its lifecycle, edit or sync its port rules, copy its SSH command,
or read recent logs. Select machines in the collection for bulk operations.
Use `Ctrl+K` / `⌘K` (or `/`) to find a machine or page. Fleet data refreshes every
five seconds while the tab is visible; host checks refresh every thirty seconds
and can also be run manually. Charts show observed machine states and allocated
resources, rather than guest CPU or memory utilization. All frontend assets,
including the font, are served locally; no frontend build step is required.
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 +434,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 +454,9 @@ python3 -B -m unittest discover -s tests -v
The unit tests do not need root, KVM, Firecracker, or host network changes.
They cover validation, state migration and locking, integrity manifests,
network/firewall rendering, Firecracker request ordering, process safety,
retained-disk resume, bulk operations, and lifecycle rollback. API route tests
run when the FastAPI and `httpx` test dependencies are installed.
retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery,
and API routes. API route tests run when the FastAPI and `httpx` test
dependencies are installed.
Opt-in packet checks run **only in disposable isolated user/network
namespaces**, which may require privileges and the installed network tools:
+130 -19
View File
@@ -602,11 +602,12 @@ inventing identity fields in `state.json`.
## 10. Use the FastAPI Server
The API uses the same local lifecycle services, but exposes only the existing
health/install/create/list/inspect/stop/delete endpoints. There are no new
start/restart, port-management, or bulk HTTP endpoints; SSH, logs, and doctor
also remain CLI-only. The server does not autostart VMs or reconcile rules,
and is not safe to expose directly to the public internet.
The API uses the same local lifecycle services and exposes health, installation,
VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port
controls. The local dashboard at `/dashboard` uses those same endpoints. There
API also supports fixed-target bulk actions; interactive SSH remains CLI-only.
The server does not autostart VMs or reconcile rules, and is not safe to expose
directly to the public internet.
### Start a Local Server
@@ -620,15 +621,18 @@ sudo env \
./uvm.py --serve --host 127.0.0.1 --port 8000
```
Liveness is at `http://127.0.0.1:8000/health`; OpenAPI docs are at
Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at
`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at
`http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`.
The API token is required even for a loopback server. All VM and installation
routes require it. FastAPI's informational `/docs`, `/redoc`, and
`/openapi.json` endpoints are also available without a token; they do not
perform host mutations. Tokens must be visible ASCII with no whitespace.
Use a protected environment file or secret manager rather than recording a
real token in shell history; do not share tokens or guest passwords in reports.
The API token is required even for a loopback server. All VM, diagnostics, and
installation routes require it. The dashboard shell, liveness route, and FastAPI's
informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available
without a token; they do not perform host mutations. The dashboard keeps an
entered token only for the active browser session. Tokens must be visible ASCII
with no whitespace. Use a protected environment file or secret manager rather
than recording a real token in shell history; do not share tokens or guest
passwords in reports.
### Start a TLS Server
@@ -675,16 +679,26 @@ curl --cacert /path/to/ca.pem \
| Method | Path | Operation |
|---|---|---|
| `GET` | `/` | Redirect to `/dashboard` |
| `GET` | `/dashboard` | Serve the local management dashboard |
| `GET` | `/health` | Unauthenticated liveness |
| `GET` | `/diagnostics` | Run host readiness checks |
| `POST` | `/install` | Install/refresh with a `force` boolean |
| `GET` | `/vms` | List VM responses |
| `POST` | `/vms` | Create and boot, HTTP 201 on success |
| `POST` | `/vms/actions` | Run selected/all VM lifecycle actions from a fixed snapshot |
| `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP |
| `POST` | `/vms/{identifier}/stop` | Stop and retain disk, using the default TERM wait |
| `DELETE` | `/vms/{identifier}` | Delete VM and private disk |
| `POST` | `/vms/{identifier}/start` | Start a retained VM |
| `POST` | `/vms/{identifier}/restart` | Restart a VM; accepts `timeout` query parameter |
| `POST` | `/vms/{identifier}/stop` | Stop and retain disk; accepts `timeout` query parameter |
| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter |
| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID |
| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID |
| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail |
These stop/delete endpoints do not expose the CLI timeout, dry-run, or bulk
flags and do not prompt for confirmation.
Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt
for confirmation. `timeout` is an optional finite, non-negative query parameter
on restart, stop, and delete; it has the same SIGTERM wait semantics as the CLI.
### Health, List, and Inspect
@@ -739,9 +753,19 @@ The response never includes the password. Omitting credentials still uses
labels return HTTP `422`. Example passwords above are placeholders; real
secrets in curl arguments can enter shell history and process listings.
### Stop and Delete
### Start, Restart, Stop, and Delete
```sh
# Start a retained private disk.
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/api-web/start"
# Restart with an explicit SIGTERM wait.
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/api-web/restart?timeout=10"
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/api-web/stop"
@@ -753,8 +777,95 @@ curl -X DELETE \
```
Stop returns the VM response with `status: "stopped"`; delete returns its
exact `id` and `status: "terminated"`. Start the retained disk through the
local CLI, not a new HTTP route.
exact `id` and `status: "terminated"`. Start and restart return the updated VM
response. Start preserves the retained private disk and does not restore guest
memory.
### Bulk Lifecycle Actions
The dashboard batch toolbar uses this endpoint for selected VMs. The server
resolves the selected identifiers into a fixed ID snapshot before it invokes the
existing serialized bulk lifecycle service.
```sh
# Restart two named VMs with a shared TERM wait.
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"action":"restart","identifiers":["api-web","worker"],"timeout":10}' \
"$UVM_API_URL/vms/actions"
# Sync managed policies for every VM currently in the registry.
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"action":"ports-sync","all_vms":true}' \
"$UVM_API_URL/vms/actions"
```
Valid actions are `start`, `stop`, `restart`, `delete`, and `ports-sync`.
Provide `identifiers` or `all_vms: true`, but not both. The response contains
one `{id, action, outcome, error}` object per selected VM. HTTP bulk requests
do not prompt for confirmation or provide a dry-run; require an explicit
operator confirmation in a caller before destructive use.
### Manage Private Ports
The port mutation and sync routes require an **exact VM ID** returned by create
or list. They deliberately do not resolve a name or guest IP, preventing a
later name/IP reuse from changing the wrong policy. Port rules are strict JSON
objects, not CLI colon strings.
```sh
# Replace the full managed TCP allowlist.
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"mode":"set","ports":[{"port":22,"protocol":"TCP"},{"port":443,"protocol":"HTTPS"}]}' \
"$UVM_API_URL/vms/<vm-id>/ports"
# Add or remove one or more rules without replacing the other rules.
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"mode":"open","ports":[{"port":80,"protocol":"HTTP"}]}' \
"$UVM_API_URL/vms/<vm-id>/ports"
curl -X POST \
-H "Content-Type: application/json" \
-H "X-UVM-Token: $UVM_API_TOKEN" \
-d '{"mode":"close","ports":[{"port":80,"protocol":"HTTP"}]}' \
"$UVM_API_URL/vms/<vm-id>/ports"
# Reapply a policy after an external firewall change.
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/<vm-id>/ports/sync"
```
`set` can use an empty `ports` array to close every managed service. A legacy
VM with `ports: null` must be stopped and use `set` before it can start or use
the other policy operations. `open`, `close`, and `sync` reject a legacy policy.
Mutating a live policy is subject to the same containment and confirmation rules
as the CLI.
### Diagnostics and Log Tails
The dashboard's System view calls the protected diagnostics endpoint. It runs
the same host-readiness checks as `uvm doctor`, but does not repair a host or
change VM state.
```sh
curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/diagnostics"
# Read the final 200 lines without opening a streaming follow request.
curl -H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/api-web/logs?tail=200"
```
Log responses have `content` and `truncated` fields. `tail` is an integer from
0 through 1000, defaulting to 100. Reads are limited to the final 256 KiB of a
log; `truncated: true` means an oversized line or the read bound prevented a
complete requested tail. Use `uvm logs VM --follow` for continuous output.
### Install or Refresh Through the API
+4 -1
View File
@@ -20,4 +20,7 @@ test = ["httpx>=0.27,<1"]
uvm = "uvm.cli:main"
[tool.setuptools]
packages = ["uvm", "uvm.firecracker", "uvm.routers"]
packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"]
[tool.setuptools.package-data]
"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js", "static/fonts/*.woff2", "static/fonts/*.txt"]
+17
View File
@@ -600,6 +600,23 @@ class LifecycleInspectionTests(LifecycleTestCase):
self.assertFalse(settings.base.exists())
self.assertEqual(self.host.events, [])
def test_read_log_tail_is_bounded_and_marks_an_oversized_line(self) -> None:
vm = self.create_vm(name="log-reader")
path = Path(vm.log)
path.write_bytes(b"old\n" * 100_000 + b"last\n" * 2)
tail = self.service.read_log_tail(vm.id, tail=2)
self.assertEqual(tail.content, "last\n" * 2)
self.assertFalse(tail.truncated)
with self.assertRaisesRegex(ValidationError, "non-negative"):
self.service.read_log_tail(vm.id, tail=-1)
path.write_bytes(b"x" * 300_000)
truncated = self.service.read_log_tail(vm.id, tail=1)
self.assertEqual(len(truncated.content), 262_144)
self.assertTrue(truncated.truncated)
def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None:
self.check_kvm.side_effect = UvmError("KVM unavailable")
self.host.failures["firewall.check_available", None] = UvmError("nft unavailable")
+207 -12
View File
@@ -13,7 +13,7 @@ from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
@@ -199,7 +199,21 @@ class FakeLifecycle:
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str) -> VmRecord:
def start(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "stopped"
self.vm.pid = None
@@ -208,10 +222,55 @@ class FakeLifecycle:
self.policy_status = "inactive"
return self.vm
def destroy(self, identifier: str) -> VmRecord:
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
return self.vm
def read_log_tail(self, identifier: str, *, tail: int) -> SimpleNamespace:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def diagnostics(self) -> list[dict]:
return [{"name": "kvm", "ok": True, "message": "available"}]
def resolve_targets(self, identifiers: list[str], *, all_vms: bool = False) -> list[VmRecord]:
if all_vms and identifiers:
raise ValidationError("--all cannot be combined with explicit VM targets")
if not all_vms and not identifiers:
raise ValidationError("specify a VM or --all")
if all_vms:
return [self.vm]
return [self.inspect(identifier).vm for identifier in identifiers]
def run_many(
self,
action: str,
vm_ids: list[str],
*,
timeout: float | None = None,
) -> list[OperationResult]:
return [OperationResult(vm_id, action, "succeeded") for vm_id in vm_ids]
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
self.inspect(identifier)
if mode == "set":
self.vm.ports = ports
elif mode == "open":
existing = {p.port: p for p in self.vm.ports or ()}
for p in ports:
existing.setdefault(p.port, p)
self.vm.ports = tuple(sorted(existing.values(), key=lambda r: r.port))
elif mode == "close":
drop = {p.port for p in ports}
self.vm.ports = tuple(p for p in self.vm.ports or () if p.port not in drop)
self.policy_status = "enforced"
return self.vm
def sync_ports(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.policy_status = "enforced"
return self.vm
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests")
class ApiRouterTests(unittest.TestCase):
@@ -236,18 +295,40 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
root = self.client.get("/", follow_redirects=False)
dashboard = self.client.get("/dashboard")
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
script = self.client.get("/dashboard/static/js/dashboard.js")
self.assertEqual(root.status_code, 307)
self.assertEqual(root.headers["location"], "/dashboard")
self.assertEqual(dashboard.status_code, 200)
self.assertIn("UVM Control", dashboard.text)
self.assertEqual(stylesheet.status_code, 200)
self.assertIn("--acid", stylesheet.text)
self.assertEqual(script.status_code, 200)
self.assertIn("sessionStorage", script.text)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
for method, path in (
("GET", "/vms"),
("POST", "/vms"),
("GET", "/vms/test-vm"),
("POST", "/vms/test-vm/stop"),
("DELETE", "/vms/test-vm"),
("POST", "/install"),
for method, path, payload in (
("GET", "/vms", {}),
("POST", "/vms", {}),
("GET", "/vms/test-vm", {}),
("GET", "/vms/test-vm/logs", {}),
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
("POST", "/vms/test-vm/start", {}),
("POST", "/vms/test-vm/restart", {}),
("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("GET", "/diagnostics", {}),
("POST", "/install", {}),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
with self.subTest(method=method, path=path, headers=headers):
response = self.client.request(method, path, headers=headers, json={})
response = self.client.request(method, path, headers=headers, json=payload)
self.assertEqual(response.status_code, 401)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(self.lifecycle.vm.status, "running")
@@ -435,6 +516,105 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(destroyed.json()["id"], "vm-test")
destroy.assert_called_with(identifier)
def test_lifecycle_timeouts_are_forwarded_when_supplied(self) -> None:
with (
patch.object(self.lifecycle, "restart", wraps=self.lifecycle.restart) as restart,
patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop,
patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy,
):
self.assertEqual(
self.client.post("/vms/vm-test/restart?timeout=1.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.post("/vms/vm-test/stop?timeout=2.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.delete("/vms/vm-test?timeout=3.5", headers=self.headers).status_code,
200,
)
restart.assert_called_once_with("vm-test", timeout=1.5)
stop.assert_called_once_with("vm-test", timeout=2.5)
destroy.assert_called_once_with("vm-test", timeout=3.5)
def test_start_restart_and_port_routes_update_vm_state(self) -> None:
started = self.client.post("/vms/vm-test/start", headers=self.headers)
self.assertEqual(started.status_code, 200)
self.assertEqual(started.json()["status"], "running")
self.assertEqual(started.json()["policy_status"], "enforced")
restarted = self.client.post("/vms/vm-test/restart", headers=self.headers)
self.assertEqual(restarted.status_code, 200)
self.assertEqual(restarted.json()["status"], "running")
self.lifecycle.vm.status = "stopped"
self.lifecycle.policy_status = "inactive"
sync = self.client.post("/vms/vm-test/ports/sync", headers=self.headers)
self.assertEqual(sync.status_code, 200)
self.assertEqual(sync.json()["id"], "vm-test")
self.assertEqual(sync.json()["policy_status"], "inactive")
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
with (
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
patch.object(self.lifecycle, "run_many", wraps=self.lifecycle.run_many) as run_many,
):
response = self.client.post(
"/vms/actions",
headers=self.headers,
json={"action": "restart", "identifiers": ["test-vm"], "timeout": 1.5},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [{"id": "vm-test", "action": "restart", "outcome": "succeeded", "error": None}])
resolve.assert_called_once_with(["test-vm"], all_vms=False)
run_many.assert_called_once_with("restart", ["vm-test"], timeout=1.5)
def test_bulk_action_requires_exactly_one_selection_strategy(self) -> None:
for payload in (
{"action": "start"},
{"action": "start", "identifiers": ["vm-test"], "all_vms": True},
):
with self.subTest(payload=payload):
response = self.client.post("/vms/actions", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422)
def test_log_and_diagnostic_routes_return_bounded_observability_data(self) -> None:
logs = self.client.get("/vms/test-vm/logs?tail=5", headers=self.headers)
diagnostics = self.client.get("/diagnostics", headers=self.headers)
self.assertEqual(logs.status_code, 200)
self.assertEqual(logs.json(), {"content": "last 5 log lines", "truncated": False})
self.assertEqual(diagnostics.status_code, 200)
self.assertEqual(diagnostics.json(), [{"name": "kvm", "ok": True, "message": "available"}])
def test_port_update_modes_mutate_rules(self) -> None:
set_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "set", "ports": [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]},
)
self.assertEqual(set_resp.status_code, 200)
self.assertEqual(set_resp.json()["ports"], [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
open_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "open", "ports": [{"port": 22, "protocol": "TCP"}]},
)
self.assertEqual(open_resp.status_code, 200)
self.assertEqual(open_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
close_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "close", "ports": [{"port": 80, "protocol": "HTTP"}]},
)
self.assertEqual(close_resp.status_code, 200)
self.assertEqual(close_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 443, "protocol": "HTTPS"}])
def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None:
for ports, status, operation, policy_status in (
(None, "running", None, "unmanaged"),
@@ -456,9 +636,15 @@ class ApiRouterTests(unittest.TestCase):
for method, path, operation in (
("GET", "/vms", "list_vms"),
("GET", "/vms/test-vm", "inspect"),
("GET", "/vms/test-vm/logs", "read_log_tail"),
("POST", "/vms", "create"),
("POST", "/vms/test-vm/start", "start"),
("POST", "/vms/test-vm/restart", "restart"),
("POST", "/vms/test-vm/stop", "stop"),
("DELETE", "/vms/test-vm", "destroy"),
("POST", "/vms/test-vm/ports", "set_ports"),
("POST", "/vms/test-vm/ports/sync", "sync_ports"),
("GET", "/diagnostics", "diagnostics"),
):
for error, expected in (
(NotFoundError("unknown guest"), 404),
@@ -480,19 +666,28 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
def test_api_schema_keeps_only_existing_operations(self) -> None:
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(
{(path, method) for path, methods in schema["paths"].items() for method in methods},
{
("/", "get"),
("/dashboard", "get"),
("/health", "get"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),
("/vms/actions", "post"),
("/vms/{identifier}", "get"),
("/vms/{identifier}/logs", "get"),
("/vms/{vm_id}/start", "post"),
("/vms/{vm_id}/restart", "post"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/diagnostics", "get"),
},
)
self.assertEqual(
+40
View File
@@ -73,6 +73,46 @@ class DestroyResponse(BaseModel):
status: str = "terminated"
class PortUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: Literal["set", "open", "close"] = "set"
ports: list[PortRuleModel] = Field(default_factory=list)
class PortSyncResponse(BaseModel):
id: str
policy_status: str
status: str
class VmLogResponse(BaseModel):
content: str
truncated: bool
class DiagnosticResponse(BaseModel):
name: str
ok: bool
message: str
class VmBulkActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["start", "stop", "restart", "delete", "ports-sync"]
identifiers: list[str] = Field(default_factory=list)
all_vms: bool = Field(default=False, strict=True)
timeout: float | None = None
class VmActionResultResponse(BaseModel):
id: str
action: str
outcome: str
error: str | None = None
def vm_response(
vm: VmRecord,
*,
+1
View File
@@ -0,0 +1 @@
# UVM Dashboard static assets package.
+943
View File
@@ -0,0 +1,943 @@
@font-face {
font-family: Manrope;
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('../fonts/manrope-latin.woff2') format('woff2');
}
:root {
--canvas: #e8e7ed;
--canvas-deep: #dddae4;
--paper: #faf9fc;
--white: #fff;
--ink: #35333e;
--ink-soft: #55515f;
--muted: #78717f;
--faint: #a29cae;
--line: rgba(110, 99, 135, .11);
--acid: #ede39f;
--yellow: #e6d783;
--yellow-soft: #f4efce;
--yellow-ink: #746633;
--peach: #ddb5a9;
--peach-soft: #f3e5e0;
--sage: #a9b9a1;
--sage-soft: #e9eee6;
--lilac: #b9aed0;
--lilac-soft: #eae4f2;
--red: #bd766e;
--red-soft: #f3e4e1;
--shadow-card: 0 5px 14px rgba(66, 52, 92, .035), 0 18px 34px -23px rgba(66, 52, 92, .17);
--radius: 21px;
--sans: Manrope, 'Avenir Next', 'Segoe UI', sans-serif;
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
--ease: cubic-bezier(.22, .68, 0, 1.01);
}
*, *::before, *::after { box-sizing: border-box; }
html { min-height: 100%; background: #dad8e0; }
body {
margin: 0;
color: var(--ink);
background: radial-gradient(ellipse at 20% 0, #eeedf1, transparent 65%), #dad8e0;
font-family: var(--sans);
font-size: 12px;
font-weight: 450;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, input, select, textarea { font: inherit; }
button, a, input, select { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button, input, select { color: inherit; }
button { border: 0; }
button:disabled { cursor: wait; opacity: .55; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p { margin: 0; }
h1, h2, h3, strong { font-weight: 550; }
h1, h2, h3 { line-height: 1.3; }
h1:focus { outline: none; }
fieldset { min-width: 0; }
input { min-width: 0; }
input::placeholder { color: #918a9e; }
input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; cursor: pointer; }
::selection { background: #ece1aa; color: var(--ink); }
:focus-visible { outline: 2px solid #a79967; outline-offset: 4px; }
[hidden] { display: none !important; }
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.icon { display: block; width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.45; stroke-linecap: round; stroke-linejoin: round; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.muted { color: var(--muted); }
.mono { font-family: var(--mono); font-size: 10px; }
.danger-text { color: #a45f57 !important; }
.subtle-label { display: block; color: var(--muted); font-size: 10px; font-weight: 450; letter-spacing: .015em; }
.dot { display: inline-block; width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--faint); }
.dot-yellow { background: var(--yellow); box-shadow: 0 0 0 3px rgba(230, 215, 131, .11); }
.dot-peach { background: var(--peach); }
.dot-sage { background: var(--sage); }
.dot-lilac { background: var(--lilac); }
.dot-error { background: var(--red); }
.status-inline { display: inline-flex; align-items: center; gap: 7px; }
.button, .icon-button, .quiet-link, .mini-link { display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.button {
min-height: 37px;
padding: 8px 17px;
border: 1px solid rgba(255, 255, 255, .8);
border-radius: 30px;
background: rgba(255, 255, 255, .57);
color: var(--ink-soft);
font-size: 11px;
font-weight: 550;
line-height: 1.4;
white-space: nowrap;
transition: background .2s, box-shadow .2s, transform .25s var(--ease);
}
.button:hover:not(:disabled) { transform: translateY(-1px); background: #fff; box-shadow: 0 4px 12px #5145660b; }
.button:active:not(:disabled) { transform: translateY(0); }
.button .icon { width: 15px; height: 15px; }
.button-primary { background: var(--acid); border-color: #f6efc5; color: #554d30; box-shadow: 0 5px 13px #9784460a; }
.button-primary:hover:not(:disabled) { background: #e9dc91; box-shadow: 0 6px 16px #97844616; }
.button-danger { background: var(--red-soft); color: #96544d; border-color: #e8cfc9; }
.button-danger:hover:not(:disabled) { background: #edd3cd; }
.button-quiet { background: transparent; border-color: transparent; color: var(--muted); box-shadow: none; }
.button-small { min-height: 31px; font-size: 10px; padding: 6px 12px; }
.button-wide { width: 100%; justify-content: space-between; padding-inline: 20px; }
.button-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.icon-button { width: 33px; height: 33px; padding: 0; border: 1px solid transparent; border-radius: 50%; background: transparent; color: var(--muted); transition: background .2s, color .2s, transform .2s; }
.icon-button:hover:not(:disabled) { background: #ffffffa8; color: var(--ink); }
.icon-button .icon { width: 16px; height: 16px; }
.quiet-link { justify-content: flex-start; padding: 0; background: transparent; color: var(--muted); font-size: 10px; font-weight: 450; transition: color .2s; }
.quiet-link:hover { color: var(--ink); }
.quiet-link .icon { width: 15px; height: 15px; }
.mini-link { color: var(--muted); padding: 3px; border-radius: 50%; }
.mini-link:hover { color: var(--ink); background: #fff8; }
.mini-link .icon { width: 13px; height: 13px; }
.round-icon { display: inline-flex; align-items: center; justify-content: center; width: 39px; height: 39px; flex: 0 0 auto; background: #f7f5fa; border: 1px solid #fff9; border-radius: 14px; color: var(--muted); }
.tag { display: inline-flex; align-items: center; gap: 6px; font-size: 10px; line-height: 1.4; padding: 6px 10px; border-radius: 20px; background: #ffffff67; color: var(--muted); white-space: nowrap; }
.tag .icon { width: 13px; height: 13px; }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; background: #ffffff69; border-radius: 20px; font-size: 10px; line-height: 1.4; font-weight: 450; color: var(--ink-soft); white-space: nowrap; }
.badge::before { content: ''; width: 5px; height: 5px; flex: 0 0 auto; background: var(--faint); border-radius: 50%; }
.badge-running::before { background: var(--yellow); }
.badge-stopped::before { background: var(--peach); }
.badge-good::before { background: #a1b292; }
.badge-attention::before { background: var(--red); }
.badge-attention { color: #a26259; background: #f5e9e57a; }
.badge-pending::before { background: var(--lilac); }
.port-chip { display: inline-flex; align-items: center; gap: 5px; border-radius: 7px; padding: 4px 7px; background: #f4f1f7; color: #756d81; font-size: 9px; white-space: nowrap; }
.port-chip small { color: #918699; font-size: 8px; }
.port-list { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; min-height: 34px; color: var(--muted); font-size: 11px; }
.port-list .port-chip { padding: 7px 11px; font-size: 11px; background: #eee9f3; }
.port-list .port-chip small { font-size: 9px; }
/* A quiet, two-part navigation panel, lifted over the workspace. */
.app-shell {
display: grid;
grid-template-columns: 402px minmax(0, 1fr);
max-width: 1840px;
height: calc(100dvh - 48px);
min-height: 680px;
margin: 24px auto;
width: calc(100% - 48px);
overflow: hidden;
border: 1px solid #ffffffac;
border-radius: 25px;
background: linear-gradient(135deg, #ecebf1 0, #e6e4ec 100%);
box-shadow: 0 24px 70px -35px #493e6640, 0 2px 7px #61546d06;
}
.workspace-drawer {
display: grid;
grid-template-columns: minmax(0, 1fr) 172px;
gap: 16px;
padding: 25px 17px 24px 20px;
overflow-y: auto;
overscroll-behavior: contain;
background: linear-gradient(125deg, #f1f0f5dd, #f0eef4ba);
border-radius: 24px 23px 28px 24px;
border-right: 1px solid #ffffff4d;
box-shadow: 7px 0 27px -18px #71628422;
z-index: 2;
scrollbar-width: none;
}
.sidebar { display: flex; flex-direction: column; min-width: 0; }
.brand { display: inline-flex; align-items: center; gap: 8px; width: fit-content; color: #4d4857; line-height: 1; }
.brand > span:nth-child(2) { font-size: 27px; font-weight: 650; letter-spacing: -1.8px; }
.brand-period { color: #b3a166; }
.brand-tag { align-self: end; margin: 0 0 3px 2px; color: #898291; font-size: 8px; letter-spacing: .025em; }
.brand-flower { display: block; position: relative; width: 20px; height: 20px; flex: 0 0 auto; transform: rotate(-12deg); }
.brand-flower i { position: absolute; width: 8px; height: 8px; border-radius: 2px 6px 2px 6px; background: var(--yellow); }
.brand-flower i:nth-child(1) { left: 1px; top: 1px; }
.brand-flower i:nth-child(2) { right: 1px; top: 1px; transform: rotate(90deg); background: #d9d3df; }
.brand-flower i:nth-child(3) { left: 1px; bottom: 1px; transform: rotate(90deg); background: #b6adbf; }
.brand-flower i:nth-child(4) { right: 1px; bottom: 1px; }
.sidebar-heading { display: flex; gap: 10px; margin: 33px 0 22px; }
.sidebar-heading-mark { margin-top: 2px; color: #827b8e; font-size: 17px; }
.sidebar-heading h2 { font-size: 20px; font-weight: 450; letter-spacing: -.055em; line-height: 1.4; }
.sidebar-heading h2 span { display: block; }
.sidebar-search { display: flex; align-items: center; gap: 9px; padding: 0 4px; color: var(--muted); background: transparent; text-align: left; font-size: 10px; }
.sidebar-search .icon { width: 15px; height: 15px; }
.sidebar-search kbd { margin-left: auto; font-family: var(--sans); font-size: 10px; color: var(--faint); }
.primary-nav { display: grid; gap: 5px; margin-top: 35px; }
.nav-item { display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 8px 10px; color: var(--muted); border-radius: 25px; font-size: 11px; font-weight: 450; transition: background .25s, box-shadow .25s, color .25s; }
.nav-item > .icon { width: 15px; height: 15px; }
.nav-item:hover { background: #ffffff5e; color: var(--ink); }
.nav-item.active { background: #ffffffed; box-shadow: 0 5px 14px #62537608; color: var(--ink); }
.nav-count { margin-left: auto; color: var(--muted); font-size: 9px; }
.nav-indicator { display: none; width: 4px; height: 4px; margin-left: auto; background: var(--lilac); border-radius: 50%; }
.nav-item.active .nav-indicator { display: block; }
.nav-health-dot { width: 5px; height: 5px; margin-left: auto; border-radius: 50%; }
.nav-item .nav-end-icon { width: 12px; height: 12px; margin-left: auto; color: var(--faint); }
.nav-create { margin-top: 9px; }
.sidebar-secondary { margin: 20px 7px 0; border-top: 1px solid var(--line); padding-top: 17px; }
.sidebar-secondary .nav-item { padding-inline: 3px; font-size: 10px; }
.sidebar-bottom { margin-top: auto; padding: 50px 5px 0; }
.workspace-identity { display: flex; align-items: center; gap: 9px; padding-top: 18px; border-top: 1px solid var(--line); }
.workspace-avatar { display: grid; place-items: center; width: 29px; height: 32px; flex: 0 0 auto; border: 1px solid #fff; border-radius: 10px; background: #e5dfed; color: #867b92; font-size: 18px; font-weight: 500; }
.workspace-identity > div { display: grid; gap: 4px; }
.workspace-identity strong { font-size: 9px; font-weight: 550; }
.workspace-identity > div > span { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 8px; line-height: 1.2; }
.workspace-identity .dot { width: 4px; height: 4px; }
.sidebar-bottom > .quiet-link { font-size: 9px; margin-top: 22px; }
.sidebar-bottom > .quiet-link .icon { width: 13px; height: 13px; }
.context-rail { display: flex; flex-direction: column; gap: 17px; min-width: 0; padding-top: 53px; }
.insight-card { position: relative; display: block; min-height: 161px; padding: 15px 14px 11px; border: 1px solid #ffffffd9; border-radius: 19px; background: linear-gradient(140deg, #fff, #fbfafc); box-shadow: var(--shadow-card); transition: transform .3s var(--ease), box-shadow .3s; }
.insight-card::before { content: ''; position: absolute; top: -3px; left: 27px; right: 27px; height: 6px; border-radius: 20px; background: #e7cfdc; filter: blur(3px); z-index: -1; }
.insight-card .card-caption { gap: 4px; font-size: 9px; }
.insight-card .card-caption .icon { width: 12px; height: 12px; color: #9b8caa; }
.insight-card p { color: var(--muted); font-size: 10px; line-height: 1.55; min-height: 42px; margin-top: 9px; }
.insight-card p strong { color: #59505f; font-weight: 650; }
.insight-matrix { display: grid; place-items: center; height: 35px; margin-top: 5px; background-image: radial-gradient(#b9adb93b .75px, transparent .9px); background-size: 4px 4px; mask-image: radial-gradient(ellipse, #000 15%, transparent 70%); }
.insight-matrix span { display: block; width: 21px; height: 21px; background: repeating-conic-gradient(#92837b 0 25%, transparent 0 50%) 50% / 4px 4px; mask-image: radial-gradient(ellipse at center 75%, #000, transparent 70%); }
.insight-footer { display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 7px; color: #99919f; }
.insight-footer .dot { width: 4px; height: 4px; }
.collection { display: block; position: relative; border-radius: 18px; transition: transform .3s var(--ease); }
.collection:hover, .insight-card:hover { transform: translateY(-3px); }
.collection-label { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 65px; padding: 13px 14px; border: 1px solid #ffffffe6; border-radius: 18px; background: linear-gradient(130deg, #fffffff7, #fcfbfee6); box-shadow: var(--shadow-card); z-index: 1; }
.collection-label > div { display: grid; gap: 3px; }
.collection-label strong { font-size: 11px; font-weight: 550; }
.collection-label span { color: var(--muted); font-size: 9px; }
.collection-label > .icon { width: 13px; height: 13px; color: #8f849f; }
.collection-art { position: relative; overflow: hidden; height: 64px; margin: 0 3px -11px; }
.paper-machine { position: absolute; width: 100px; height: 91px; padding: 14px; border: 1px solid #fff; border-radius: 10px; background: linear-gradient(150deg, #fff, #eae8ed); box-shadow: -3px 1px 12px #6f5d8810; }
.back-paper { left: 11px; top: 18px; transform: rotate(-14deg); }
.front-paper { right: 6px; top: 5px; transform: rotate(9deg); }
.paper-machine i { display: block; height: 2px; margin-top: 5px; border-radius: 3px; background: #dcd8e1; }
.paper-machine i:nth-last-child(2) { width: 60%; }
.paper-machine > span { display: block; width: 11px; height: 3px; background: #e7dca5; border-radius: 3px; margin-top: 5px; }
.paper-machine .icon { width: 18px; height: 18px; color: #b5aaba; margin-bottom: 6px; }
.collection-machines::after { content: ''; position: absolute; bottom: -2px; left: 21px; right: 21px; height: 13px; background: #ebe5c9; border-radius: 50%; filter: blur(10px); }
.network-art { height: 62px; margin-bottom: -12px; }
.network-paper { position: absolute; left: 0; right: 0; top: 7px; height: 79px; padding: 12px; border: 1px solid #eeeeef; border-radius: 16px; background: #e7e4ebaa; box-shadow: inset 0 7px 9px #fff6; }
.network-paper::before { content: ''; position: absolute; left: 10px; right: 10px; top: 9px; height: 67px; background: #f3f0f7; border-radius: 11px; box-shadow: 0 0 0 1px #fff7; }
.network-paper .icon { position: absolute; width: 16px; height: 16px; left: 18px; top: 19px; color: #b8acbe; }
.network-paper i { position: absolute; top: 33px; width: 5px; height: 5px; border-radius: 50%; background: #c9bece; }
.network-paper i:nth-of-type(1) { left: 50px; }
.network-paper i:nth-of-type(2) { left: 88px; top: 19px; background: #e3d694; }
.network-paper i:nth-of-type(3) { right: 13px; }
.network-line { position: absolute; left: 52px; right: 16px; top: 20px; height: 17px; border-top: 1px solid #d9cedf; border-left: 1px solid #d9cedf; border-right: 1px solid #d9cedf; border-radius: 5px; z-index: 1; }
.collection-network::after { content: ''; position: absolute; left: 14px; right: 14px; bottom: -3px; height: 10px; border-radius: 20px; background: #e4cad6; filter: blur(5px); }
.collection-new .collection-label { background: #faf9fc9c; border-color: #ffffff96; box-shadow: 0 5px 15px #62537603; }
.rail-footnote { margin-top: auto; padding: 7px 14px 0; color: #a098ac; font-size: 8px; line-height: 1.65; }
/* The canvas stays light; grouping comes from tone and space. */
.workspace { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.topbar { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 0 27px; }
.workspace-crumb { display: inline-flex; align-items: center; gap: 8px; min-height: 29px; padding: 5px 13px; border-radius: 25px; background: #ffffffab; box-shadow: 0 3px 12px #67577d03; font-size: 10px; white-space: nowrap; }
.workspace-crumb .dot { width: 5px; height: 5px; }
.crumb-divider { color: #c0b9c9; padding: 0 1px; }
#breadcrumb { color: #8e8499; }
.refresh-button { width: 28px; height: 28px; }
.refresh-button .icon { width: 13px; height: 13px; }
.refresh-button.is-loading .icon { animation: spin 1.3s linear infinite; }
.global-search { position: relative; display: flex; align-items: center; gap: 8px; max-width: 260px; min-width: 135px; width: 100%; margin-left: auto; padding: 5px 2px; }
.global-search > .icon { width: 14px; height: 14px; color: #62596f; }
.global-search input { width: 100%; outline: none; border: 0; background: transparent; font-size: 10px; padding: 4px 0; }
.global-search:focus-within { border-bottom: 1px solid #c7bdd3; }
.global-search kbd { white-space: nowrap; color: #a499b1; font-family: var(--sans); font-size: 9px; }
.topbar-create { width: 29px; height: 29px; border: 1px solid #fff9; background: #ffffff60; margin-left: 6px; }
.mobile-menu { display: none; }
.page-content { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 12px 27px 25px; scrollbar-gutter: stable; }
.page { animation: page-appear .35s var(--ease) both; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 23px; min-height: 52px; }
.page-heading h1 { font-size: 27px; font-weight: 450; letter-spacing: -.055em; margin-top: 5px; overflow-wrap: anywhere; }
.heading-period { color: #aa9aac; }
.page-heading .subtle-label { font-size: 9px; }
.date-label { font-size: 9px; color: #898090; align-self: end; padding-bottom: 4px; }
.overview-metrics { display: grid; grid-template-columns: .98fr 1.02fr 1fr; gap: 13px; }
.metric-card { min-width: 0; min-height: 153px; padding: 16px 18px; border: 1px solid #ffffff73; border-radius: var(--radius); background: linear-gradient(120deg, #f9f8fba8, #f6f4f987); box-shadow: 0 5px 15px #55456602; }
.card-caption { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.card-caption h2 { font-size: 12px; font-weight: 500; letter-spacing: -.025em; }
.caption-icon { width: 15px; height: 15px; color: #a59bb0; }
.fleet-number { display: flex; align-items: center; gap: 16px; margin: 12px 0 13px; }
.fleet-number > strong { font-size: 39px; font-weight: 350; letter-spacing: -.07em; line-height: 1.1; }
.fleet-number > span { color: var(--muted); font-size: 9px; line-height: 1.55; }
.metric-bottom { display: flex; justify-content: space-between; gap: 8px; font-size: 9px; }
.metric-bottom .dot { width: 5px; height: 5px; }
.signal-readouts { display: flex; justify-content: space-between; gap: 10px; margin-top: 16px; }
.signal-readouts > div { display: grid; align-items: center; grid-template-columns: 5px 1fr; gap: 2px 6px; }
.signal-readouts .dot { width: 4px; height: 4px; }
.signal-readouts strong { font-size: 16px; font-weight: 450; line-height: 1.4; }
.signal-readouts small { grid-column: 2; color: var(--muted); font-size: 9px; }
.segmented-track { display: flex; height: 3px; margin-top: 18px; border-radius: 5px; background: #e2dce9; overflow: hidden; }
.segmented-track span { width: 0; transition: width .7s var(--ease); }
.segmented-track span:first-child { background: linear-gradient(90deg, #b3c0a9, #d4dba6); }
.segmented-track span:last-child { background: linear-gradient(90deg, #e0c4ae, #dbaa9f); }
.range-labels { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; color: #94899f; font-size: 7px; }
.resource-line { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; margin-top: 19px; }
.resource-line > span { font-size: 12px; letter-spacing: -.02em; }
.resource-line > small { color: var(--muted); font-size: 8px; text-align: right; }
.resource-track { height: 3px; border-radius: 4px; background: #e5dfca8a; margin-top: 6px; overflow: hidden; }
.resource-track > span { display: block; width: 0; height: 100%; border-radius: inherit; background: #e6d88e; box-shadow: 0 0 5px #e7d89555; transition: width .7s var(--ease); }
.second-resource { margin-top: 15px; }
.memory-track { background: #e6dfeb; }
.memory-track > span { background: #beb0d1; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
.section-heading h2 { font-size: 14px; font-weight: 500; letter-spacing: -.03em; }
.section-heading > div > span { display: block; color: var(--muted); font-size: 10px; margin-top: 4px; }
.portfolio-section { margin-top: 26px; }
.portfolio-section > .section-heading { margin: 0 3px 12px; }
.portfolio-content { min-height: 184px; }
.table-scroll { overflow-x: auto; scrollbar-width: thin; }
.portfolio-table { width: 100%; min-width: 565px; border-collapse: separate; border-spacing: 0 6px; text-align: left; }
.portfolio-table th { color: var(--muted); font-size: 10px; font-weight: 450; padding: 0 10px 5px; white-space: nowrap; }
.portfolio-table td { padding: 12px 10px; color: var(--ink-soft); font-size: 11px; transition: background .2s; }
.portfolio-table th:first-child, .portfolio-table td:first-child { padding-left: 14px; }
.portfolio-table tbody tr { background: #faf8fc22; }
.portfolio-table tbody tr:nth-child(2n) { background: #ffffff7d; }
.portfolio-table tbody tr:hover { background: #ffffffbf; }
.portfolio-table td:first-child { border-radius: 13px 0 0 13px; }
.portfolio-table td:last-child { border-radius: 0 13px 13px 0; width: 25px; padding-inline: 6px; }
.portfolio-table .badge { font-size: 9px; padding: 4px 7px; }
.portfolio-table .mini-link .icon { width: 12px; height: 12px; }
.portfolio-name { display: flex; align-items: center; gap: 9px; color: var(--ink); font-size: 11px; }
.portfolio-name > .icon { width: 13px; height: 13px; color: #b4a6bd; }
.portfolio-name a:hover { text-decoration: underline; text-underline-offset: 3px; }
.resource-inline { white-space: nowrap; font-size: 10px; }
.resource-inline span { color: #aca0b6; padding: 0 4px; }
.soft-panel { min-width: 0; padding: 21px; border: 1px solid #ffffff69; border-radius: var(--radius); background: linear-gradient(120deg, #f7f5fa8c, #f3f0f775); }
.overview-bottom { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; margin-top: 22px; }
.activity-panel { padding: 19px 20px 13px; }
.activity-panel .section-heading { margin-bottom: 8px; }
.activity-body { display: flex; align-items: center; justify-content: center; gap: 34px; }
.fleet-ring { position: relative; width: 208px; max-width: 56%; flex: 0 0 auto; aspect-ratio: 1; }
.fleet-ring > svg { display: block; width: 100%; height: 100%; overflow: visible; }
.fleet-ring circle { fill: none; stroke-width: 3; }
.ring-track { stroke: #e2dce9; }
.ring-segment { transform: rotate(-90deg); transform-origin: center; stroke-dasharray: 0 100; stroke-linecap: round; transition: stroke-dasharray .8s var(--ease), stroke-dashoffset .8s var(--ease); }
.ring-running { stroke: #e7d98b; }
.ring-stopped { stroke: #dcb2a5; }
.ring-other { stroke: #baabcb; }
.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; padding-top: 7px; }
.ring-center > span { color: var(--muted); font-size: 9px; }
.ring-center > strong { font-size: 43px; font-weight: 250; letter-spacing: -.06em; line-height: 1.4; }
.ring-center > strong small { font-size: 26px; font-weight: 300; }
.ring-center > span:last-child { font-size: 8px; }
.ring-legend { display: grid; gap: 21px; flex: 1; max-width: 150px; padding-right: 3px; }
.ring-legend > div { display: flex; align-items: flex-start; gap: 7px; }
.ring-legend .dot { width: 4px; height: 4px; margin-top: 5px; }
.ring-legend span { color: var(--ink-soft); font-size: 10px; line-height: 1.4; }
.ring-legend small { display: block; color: var(--muted); font-size: 8px; margin-top: 4px; }
.ring-legend strong { margin-left: auto; color: var(--ink-soft); font-size: 13px; font-weight: 400; }
.notes-panel { padding: 20px 20px 15px; }
.notes-panel .section-heading { margin-bottom: 12px; }
.note-row { display: flex; align-items: center; gap: 10px; padding: 13px 0; border-bottom: 1px solid #b5a6c016; }
.note-row > .icon { width: 15px; height: 15px; color: #a89ab3; }
.note-row > div { min-width: 0; }
.note-row strong { display: block; color: var(--ink-soft); font-size: 11px; font-weight: 450; }
.note-row small { display: block; color: var(--muted); font-size: 9px; margin-top: 2px; }
.note-row > span { margin-left: auto; font-size: 11px; color: var(--muted); white-space: nowrap; }
.note-row .icon:last-child { width: 11px; height: 11px; }
.note-row:hover strong { color: #4f405b; }
.notes-footer { margin-top: 15px; width: 100%; justify-content: space-between; font-size: 9px; }
.page-footer { display: flex; justify-content: space-between; gap: 12px; color: #a292ad; margin: 21px 3px 0; font-size: 8px; }
.page-footer > span:first-child { display: flex; align-items: center; gap: 6px; }
.page-footer .dot { width: 4px; height: 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 215px; padding: 30px 18px; text-align: center; border-radius: 18px; background: #f9f7fc47; }
.empty-state .empty-icon { position: relative; display: grid; place-items: center; width: 43px; height: 43px; margin-bottom: 4px; border: 1px solid #ffffffed; border-radius: 15px; background: linear-gradient(135deg, #fff, #e7e1ee); color: #b0a1bd; box-shadow: 0 8px 13px -8px #9183aa38; transform: rotate(-7deg); }
.empty-state .empty-icon::before { content: ''; position: absolute; inset: 4px -5px -3px 5px; border: 1px solid #ffffffbf; border-radius: 15px; z-index: -1; }
.empty-state h3 { font-size: 14px; font-weight: 450; letter-spacing: -.035em; }
.empty-state p { max-width: 290px; color: var(--muted); font-size: 11px; }
.empty-state .button { margin-top: 7px; }
.portfolio-content .empty-state { min-height: 191px; padding: 22px 15px; border: 1px solid #ffffff4d; }
.portfolio-content .empty-state h3 { font-size: 13px; }
.portfolio-content .empty-state p { font-size: 9px; max-width: none; }
.portfolio-content .empty-state .button { min-height: 29px; font-size: 9px; }
.loading-state { display: grid; place-items: center; min-height: 170px; color: var(--muted); font-size: 11px; }
/* The machine collection. */
.compact-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; margin-bottom: 25px; }
.compact-metric { display: flex; align-items: center; gap: 15px; min-height: 100px; padding: 20px; border: 1px solid #ffffff80; border-radius: 19px; background: #f9f7fba6; }
.compact-metric > .round-icon { width: 37px; height: 41px; background: #eee8f3; color: #9d8daa; }
.compact-metric:nth-child(2) > .round-icon { background: #f3eed5; color: #ac9a65; }
.compact-metric:nth-child(3) > .round-icon { background: #efe2e0; color: #ba9692; }
.compact-metric > div { min-width: 0; }
.compact-metric strong { display: block; font-size: 25px; font-weight: 350; line-height: 1.25; letter-spacing: -.05em; overflow-wrap: anywhere; }
.compact-metric span { color: var(--muted); font-size: 10px; }
.compact-metric .metric-word { font-size: 18px; }
.fleet-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 15px; }
.filter-tabs { display: flex; gap: 3px; padding: 4px; border: 1px solid #ffffff57; border-radius: 28px; background: #ffffff21; }
.filter-tab { padding: 8px 13px; border-radius: 22px; background: transparent; color: var(--muted); font-size: 11px; transition: background .2s, color .2s, box-shadow .2s; white-space: nowrap; }
.filter-tab.active { background: #fff; color: #5f496c; box-shadow: 0 3px 9px #68517508; }
.filter-tab:hover { color: var(--ink); }
.select-wrap { position: relative; display: inline-flex; align-items: center; }
.select-wrap::after { content: ''; position: absolute; right: 12px; width: 5px; height: 5px; border-right: 1px solid #92819f; border-bottom: 1px solid #92819f; transform: translateY(-2px) rotate(45deg); pointer-events: none; }
.select-wrap select { appearance: none; max-width: 100%; min-height: 32px; border: 1px solid #ffffff70; border-radius: 18px; background: #f7f4fa9c; padding: 6px 28px 6px 12px; color: #897494; font-size: 9px; cursor: pointer; }
.fleet-list-panel { padding: 5px 16px 0; border: 1px solid #ffffff73; border-radius: 21px; background: #f8f5fb57; }
.list-search-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 1px 13px; }
.search-field { display: flex; align-items: center; gap: 10px; max-width: 310px; width: 100%; color: #a18caf; }
.search-field .icon { width: 15px; height: 15px; }
.search-field input { width: 100%; padding: 7px 0; border: 0; outline: 0; background: transparent; font-size: 10px; }
.search-field:focus-within { border-bottom: 1px solid #c7b9d4; }
.list-search-row > span { font-size: 9px; color: #a18cab; white-space: nowrap; }
.machine-table { width: 100%; min-width: 690px; border-collapse: separate; border-spacing: 0 7px; text-align: left; }
.machine-table th { padding: 3px 9px 9px; color: var(--muted); font-size: 10px; font-weight: 450; }
.machine-table td { padding: 14px 9px; background: #ffffff91; color: var(--ink-soft); font-size: 11px; transition: background .2s; }
.machine-table tbody tr:nth-child(2n) td { background: #ffffff47; }
.machine-table tbody tr:hover td { background: #fff; }
.machine-table tr.selected td { background: #f6f0d9bb; }
.machine-table td:first-child { border-radius: 13px 0 0 13px; }
.machine-table td:last-child { border-radius: 0 13px 13px 0; }
.machine-table .check-cell { width: 28px; padding-right: 3px; padding-left: 10px; }
.machine-table input[type='checkbox'] { vertical-align: middle; }
.machine-name { display: flex; align-items: center; gap: 9px; }
.machine-name > .round-icon { width: 29px; height: 33px; border-radius: 10px; background: #eee7f280; }
.machine-name .round-icon .icon { width: 15px; height: 15px; color: #b39cc0; }
.machine-name > div { min-width: 80px; }
.machine-name a { display: block; max-width: 175px; color: var(--ink); font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-name a:hover { text-decoration: underline; text-underline-offset: 4px; }
.machine-name small { display: block; color: var(--muted); font-size: 9px; margin-top: 3px; }
.allocation-cell { white-space: nowrap; }
.allocation-cell small { display: block; color: var(--muted); font-size: 10px; margin-top: 3px; }
.table-ports { display: flex; flex-wrap: wrap; gap: 3px; max-width: 135px; }
.table-ports .muted { font-size: 9px; color: #a78cb3; }
.row-controls { display: flex; align-items: center; justify-content: flex-end; gap: 1px; }
.row-controls .icon-button { width: 27px; height: 27px; color: #aa91b6; }
.row-controls .icon { width: 13px; height: 13px; }
.row-controls-heading { width: 67px; }
.list-footer { display: flex; justify-content: space-between; gap: 15px; padding: 17px 1px; color: #aa90b6; font-size: 8px; }
.bulk-controls { display: flex; align-items: center; gap: 9px; margin: 2px 0 13px; padding: 7px 10px; border: 1px solid #f6efc8; border-radius: 14px; background: #f3edc67d; }
.bulk-controls > span { margin-right: auto; font-size: 10px; color: #8a7945; }
.bulk-controls .icon-button { width: 25px; height: 25px; }
.operation-results { margin-top: 18px; padding: 17px 20px; border: 1px solid #fff8; border-radius: 17px; background: #f7f1eb; color: #957364; }
.operation-results header { display: flex; align-items: center; justify-content: space-between; gap: 15px; }
.operation-results h2 { font-size: 12px; }
.operation-results ul { margin: 7px 0 0; padding-left: 17px; font-size: 10px; }
.operation-results li { padding: 3px 0; overflow-wrap: anywhere; }
/* Network and inspection. */
.network-layout { display: grid; grid-template-columns: .85fr 1.4fr; gap: 16px; }
.network-map-panel { padding: 24px; }
.fabric-diagram { display: flex; flex-direction: column; align-items: center; padding: 23px 0; }
.fabric-node { display: flex; flex-direction: column; align-items: center; gap: 5px; position: relative; z-index: 1; }
.fabric-node > .round-icon { background: #fbf9fc; width: 45px; height: 45px; border-radius: 15px; margin-bottom: 4px; box-shadow: 0 8px 16px #78618c07; }
.fabric-node strong { font-size: 11px; font-weight: 500; }
.fabric-node small { color: #a08bac; font-size: 8px; }
.fabric-connector { width: 1px; height: 31px; margin: 8px 0; background: linear-gradient(#d7cbe1, #dccf94); }
.fabric-gateway .round-icon { background: #f4eecf; color: #b09d61; }
.fabric-branches { position: relative; display: flex; justify-content: space-between; width: 150px; height: 29px; margin-top: 21px; border-top: 1px solid #d8cce0; border-radius: 6px; }
.fabric-branches::before { content: ''; position: absolute; left: 50%; bottom: 100%; width: 1px; height: 21px; background: #d8cce0; }
.fabric-branches span { display: block; width: 1px; height: 20px; background: #d8cce0; }
.fabric-guests { display: flex; justify-content: space-between; width: 173px; margin-bottom: 16px; }
.fabric-guests .icon { width: 24px; height: 24px; color: #bba8c8; }
.fabric-diagram > .muted { font-size: 9px; color: #a38baf; }
.panel-footnote { color: var(--muted); font-size: 10px; line-height: 1.8; margin-top: 13px; overflow-wrap: anywhere; }
.policy-row { display: block; padding: 16px 2px; border-bottom: 1px solid #b9a6c51f; }
.policy-row:last-child { border-bottom: 0; }
.policy-row-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 7px; }
.policy-row-heading strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.policy-row-heading .icon { width: 13px; height: 13px; color: #ad97b9; }
.policy-row-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; color: var(--muted); font-size: 10px; }
.policy-row .table-ports { max-width: none; }
.policy-row:hover strong { color: #967249; }
.back-link { margin: 3px 0 21px; }
.detail-heading { margin-bottom: 22px; }
.detail-heading #detail-id { display: block; margin-top: 7px; color: #a58cb2; font-size: 8px; word-break: break-all; }
.detail-heading h1 { max-width: 450px; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.detail-card { padding: 23px; }
.detail-list { margin: 0; }
.detail-list > div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; padding: 10px 0; border-bottom: 1px solid #bcabca21; }
.detail-list > div:last-child { border-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 11px; }
.detail-list dd { max-width: 66%; margin: 0; color: var(--ink-soft); text-align: right; font-size: 11px; overflow-wrap: anywhere; }
.detail-list .mono { font-size: 10px; }
.ssh-panel { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 15px; padding: 18px 21px; }
.ssh-panel > div:first-child { display: flex; align-items: center; gap: 12px; }
.ssh-panel h2 { font-size: 12px; font-weight: 500; }
.ssh-panel p { font-size: 10px; color: var(--muted); margin-top: 4px; max-width: 300px; }
.ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; }
.ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; }
.ssh-command .icon-button { width: 27px; height: 27px; }
.ports-panel, .log-panel { margin-top: 15px; }
.port-editor { padding-top: 5px; }
.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; }
/* Provisioning uses the same paper-like surfaces. */
.create-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(220px, .9fr); align-items: start; gap: 17px; }
.create-fields { display: grid; gap: 15px; min-width: 0; }
.form-section { padding: 24px; }
.form-section .section-heading { margin-bottom: 22px; }
.form-section .section-heading h2 { display: flex; align-items: center; gap: 9px; }
.form-section .section-heading > div > span { padding-left: 31px; font-size: 9px; }
.step-number { display: inline-grid; place-items: center; width: 23px; height: 23px; border-radius: 50%; background: #e9e0ee; color: #ac91b7; font-size: 9px; }
.field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.field > span:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--ink-soft); font-size: 11px; }
.field small { color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.6; }
.field input { width: 100%; min-height: 39px; padding: 10px 12px; border: 1px solid #ffffffb3; border-radius: 11px; background: #fff9; color: var(--ink-soft); font-size: 12px; transition: background .2s, border-color .2s, box-shadow .2s; }
.field input:focus { outline: none; background: #ffffffed; border-color: #c3b3a0; box-shadow: 0 0 0 3px #e6dca329; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 18px; }
.preset-fieldset { margin: 19px 0 0; padding: 0; border: 0; }
.preset-fieldset legend { color: var(--muted); font-size: 10px; padding: 0 0 9px; }
.size-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
.size-preset { display: flex; flex-direction: column; align-items: flex-start; gap: 5px; padding: 12px; border: 1px solid #fff9; border-radius: 13px; background: #ffffff3b; transition: background .2s, border-color .2s; }
.size-preset .icon { width: 17px; height: 17px; color: #b59dbf; margin-bottom: 4px; }
.size-preset strong { font-size: 11px; font-weight: 450; color: var(--ink-soft); }
.size-preset span { font-size: 9px; color: var(--muted); white-space: nowrap; }
.size-preset.active { border-color: #e6dca2; background: #f6f0d991; }
.size-preset.active .icon { color: #b7a367; }
.size-preset.active strong { color: #9b8854; }
.size-preset:hover { background: #fff9; }
.password-field { display: flex; align-items: center; position: relative; }
.password-field input { padding-right: 37px; }
.password-field > .icon-button { position: absolute; right: 5px; width: 27px; height: 27px; }
.toggle-field { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 18px; cursor: pointer; }
.toggle-field > span:first-child { display: grid; gap: 4px; }
.toggle-field strong { font-size: 11px; font-weight: 450; color: var(--ink-soft); }
.toggle-field small { font-size: 9px; color: var(--muted); }
.toggle-field > input { position: absolute; right: 0; opacity: 0; width: 33px; height: 20px; }
.toggle-track { position: relative; width: 31px; height: 18px; flex: 0 0 auto; border-radius: 15px; background: #d8cce1; transition: background .25s; }
.toggle-track::after { content: ''; position: absolute; width: 12px; height: 12px; top: 3px; left: 3px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #79658721; transition: transform .25s var(--ease); }
.toggle-field > input:checked + .toggle-track { background: #d7c782; }
.toggle-field > input:checked + .toggle-track::after { transform: translateX(13px); }
.toggle-field > input:focus-visible + .toggle-track { outline: 2px solid #aa9560; outline-offset: 4px; }
.create-summary { position: sticky; top: 0; padding: 24px 22px; background: linear-gradient(140deg, #fefcffa6, #f5f0f76b); }
.summary-art { position: relative; height: 105px; margin-bottom: 20px; }
.summary-art > div, .summary-art > span { position: absolute; width: 90px; height: 72px; left: 50%; top: 14px; border: 1px solid #fff; border-radius: 17px; background: #eeebf1; box-shadow: 0 10px 21px -10px #9b87ad40; }
.summary-art > div:nth-child(1) { transform: translateX(-38%) rotate(15deg); background: #e8e2ed; }
.summary-art > div:nth-child(2) { transform: translateX(-62%) rotate(-12deg); background: #f4f0f8; }
.summary-art > span { display: grid; place-items: center; transform: translateX(-50%) rotate(-3deg); background: linear-gradient(130deg, #fffeff, #f1eadb); }
.summary-art .icon { width: 31px; height: 31px; color: #b8a990; }
.create-summary > .subtle-label { font-size: 8px; color: #ab91b3; }
.create-summary > h2 { margin-top: 8px; color: var(--ink-soft); font-size: 20px; font-weight: 400; letter-spacing: -.055em; overflow-wrap: anywhere; }
.create-summary > p:not(.form-error) { margin-top: 10px; color: var(--muted); font-size: 10px; }
.create-summary .detail-list { margin: 23px 0; }
.create-summary .detail-list dt, .create-summary .detail-list dd { font-size: 10px; }
.create-summary .button { font-size: 10px; }
.summary-cancel { display: flex; justify-content: center; margin-top: 13px; font-size: 9px; color: #b29ab9; }
.summary-note { display: flex; justify-content: center; align-items: center; gap: 6px; color: #b9a3bd; font-size: 8px; margin-top: 26px; }
.summary-note .icon { width: 12px; height: 12px; }
.form-error { color: #b1726b; font-size: 10px; line-height: 1.7; margin: 9px 0; overflow-wrap: anywhere; }
.form-error:empty { display: none; }
/* Host readiness and installation. */
.health-layout { display: grid; grid-template-columns: 1.35fr 1fr; gap: 16px; align-items: start; }
.health-side { display: grid; gap: 16px; }
.diagnostics-panel { padding: 22px; }
.diagnostics-list { display: grid; gap: 8px; }
.diagnostic-item { display: flex; align-items: flex-start; gap: 11px; padding: 13px 12px; border: 1px solid #ffffff61; border-radius: 13px; background: #ffffff47; }
.diagnostic-item > .diagnostic-icon { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 auto; border-radius: 9px; background: #e8ebe1; color: #98a383; }
.diagnostic-icon .icon { width: 13px; height: 13px; }
.diagnostic-item.attention > .diagnostic-icon { background: #efe0d8; color: #c19e8a; }
.diagnostic-item > div { min-width: 0; flex: 1; }
.diagnostic-item h3 { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; color: var(--ink-soft); font-size: 11px; font-weight: 500; }
.diagnostic-item h3 > span { font-size: 9px; font-weight: 450; white-space: nowrap; color: #7a8562; }
.diagnostic-item.attention h3 > span { color: #a2856a; }
.diagnostic-item p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
.install-panel { padding: 24px; }
.install-panel > .round-icon { width: 44px; height: 44px; background: #f2eacb; color: #b09c67; margin-bottom: 18px; }
.install-panel h2 { font-size: 16px; font-weight: 450; letter-spacing: -.04em; }
.install-panel > p { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.8; }
.install-assets { display: grid; gap: 12px; margin: 24px 0; padding: 17px 0; border-block: 1px solid #beabcc24; }
.install-assets > span { display: flex; align-items: center; gap: 9px; font-size: 11px; color: var(--muted); }
.install-assets .icon { width: 14px; height: 14px; color: #bca5c3; }
.install-panel .toggle-field { margin-top: 6px; }
.install-panel .toggle-field strong { font-size: 9px; }
.install-panel .button { font-size: 10px; }
.install-result { max-height: 280px; margin: 17px 0 0; padding: 13px; border-radius: 12px; background: #e9e1ee; color: #95749d; font-family: var(--mono); font-size: 9px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; }
.server-info { padding: 23px; }
.server-info .detail-list { margin: 0 0 18px; }
.server-info .detail-list > div { flex-direction: column; gap: 3px; }
.server-info .detail-list dd { max-width: 100%; text-align: left; }
.server-info .quiet-link { font-size: 9px; }
/* Authentication is part of the same workspace, not a different product. */
.auth-screen { display: grid; place-items: center; min-height: 100dvh; padding: 32px; }
.auth-window { display: grid; grid-template-columns: 1.08fr 1fr; width: min(100%, 990px); min-height: 620px; overflow: hidden; border: 1px solid #ffffffe0; border-radius: 29px; background: #f3f0f6; box-shadow: 0 25px 75px -36px #7158834d; }
.auth-story { display: flex; flex-direction: column; position: relative; padding: 35px 40px 26px; overflow: hidden; background: linear-gradient(130deg, #edeaf1, #e4deeb); border-right: 1px solid #ffffffa1; border-radius: 28px; }
.auth-story-copy { position: relative; margin-top: 55px; z-index: 1; }
.auth-story-copy > .subtle-label { font-size: 9px; color: #a68dad; }
.auth-story-copy h1 { font-size: 43px; letter-spacing: -.065em; line-height: 1.2; font-weight: 400; color: #64556e; margin-top: 14px; }
.auth-story-copy p { color: var(--muted); font-size: 12px; line-height: 1.9; margin-top: 18px; }
.auth-sculpture { position: relative; flex: 1; min-height: 255px; }
.sculpture-card { position: absolute; width: 155px; height: 125px; left: 50%; top: 74px; border-radius: 22px; border: 1px solid #ffffffe0; background: linear-gradient(130deg, #fefcff, #eee6f1); box-shadow: 0 18px 25px -15px #7b608d59; }
.sculpture-back { transform: translateX(-63%) rotate(-22deg) translateY(-25px); background: #e4dbea; }
.sculpture-middle { transform: translateX(-37%) rotate(15deg) translateY(-15px); }
.sculpture-front { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 12px; padding: 20px 24px; transform: translateX(-50%) rotate(-4deg); background: linear-gradient(130deg, #fff, #f2ecd9); }
.sculpture-front > .icon { width: 30px; height: 30px; color: #b5a68c; }
.sculpture-front > span { color: #a58c8a; font-size: 10px; }
.sculpture-lights { display: flex; gap: 5px; }
.sculpture-lights i { display: block; width: 4px; height: 4px; border-radius: 50%; background: #e0cf85; }
.sculpture-lights i:nth-child(2) { background: #d5cbdc; }
.sculpture-orbit { position: absolute; width: 260px; height: 260px; left: 50%; top: 3px; border: 1px solid #c9b9d438; border-radius: 50%; transform: translateX(-50%); }
.orbit-inner { width: 206px; height: 206px; top: 30px; border-color: #fff7; }
.orbit-outer::after { content: ''; position: absolute; top: 31px; left: 36px; width: 7px; height: 7px; border-radius: 50%; background: #e1d094; box-shadow: 0 0 0 5px #e6d9b326; }
.sculpture-caption { position: absolute; left: 0; right: 0; top: 240px; display: flex; align-items: center; justify-content: center; gap: 6px; color: #af93b5; font-size: 8px; }
.sculpture-caption .dot { width: 4px; height: 4px; }
.auth-story-footer { position: relative; margin-top: 37px; color: #ae96b5; font-size: 8px; }
.auth-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; padding: 48px 45px; background: linear-gradient(150deg, #faf8fc, #f1ebf5); }
.auth-panel-inner { width: 100%; max-width: 310px; }
.auth-lock { width: 43px; height: 43px; background: #eee7f2; color: #a18aaa; margin-bottom: 28px; }
.auth-panel h2 { margin: 9px 0 13px; font-size: 27px; font-weight: 400; letter-spacing: -.055em; color: #735b7d; }
.auth-panel-inner > p { color: var(--muted); font-size: 12px; line-height: 1.85; }
.auth-panel form { margin-top: 29px; }
.auth-panel .field input { background: #fdfbfe; border-color: #e7dcec; min-height: 43px; }
.auth-panel .button { margin-top: 16px; min-height: 41px; }
.auth-panel-inner > .auth-note { display: flex; align-items: center; gap: 6px; font-size: 8px; color: #b39bbd; margin-top: 19px; }
.auth-note .icon { width: 12px; height: 12px; }
.auth-help { position: absolute; bottom: 30px; color: #b39bbd; font-size: 8px; }
/* Small interactions, accessible dialogs, and a useful search. */
.search-results { position: absolute; top: calc(100% + 10px); right: -8px; width: 330px; max-width: calc(100vw - 40px); max-height: 380px; padding: 7px; overflow-y: auto; z-index: 12; border: 1px solid #fff; border-radius: 17px; background: #faf7fceF; box-shadow: 0 12px 35px #6f538526; backdrop-filter: blur(20px); }
.search-result { display: flex; align-items: center; gap: 9px; padding: 10px; border-radius: 11px; color: #8b6f98; font-size: 10px; }
.search-result[aria-selected='true'], .search-result:hover { background: #eee5f4; }
.search-result > .icon { width: 15px; height: 15px; color: #b398c2; }
.search-result > div { min-width: 0; flex: 1; }
.search-result strong { display: block; font-size: 10px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-result small { display: block; margin-top: 2px; font-size: 8px; color: #b296c0; }
.search-empty { padding: 20px; color: #ac8cb9; font-size: 10px; }
.action-menu { position: fixed; width: 181px; padding: 6px; z-index: 30; border: 1px solid #fff; border-radius: 15px; background: #fcf9feee; backdrop-filter: blur(18px); box-shadow: 0 12px 30px #71538025; }
.action-menu button { display: flex; align-items: center; gap: 9px; padding: 10px; width: 100%; border-radius: 10px; background: transparent; color: #8b6b9a; font-size: 10px; text-align: left; }
.action-menu button:hover, .action-menu button:focus { background: #ede3f4; outline: none; }
.action-menu .icon { width: 14px; height: 14px; color: #b599c3; }
.action-menu .menu-danger { color: #b77970; }
.action-menu .menu-danger .icon { color: #c7978f; }
.notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; margin-bottom: 20px; border: 1px solid #fff9; border-radius: 13px; background: #f3ebe0; color: #a38a67; font-size: 10px; overflow-wrap: anywhere; }
.notice > .icon { width: 15px; height: 15px; }
.notice > .quiet-link { margin-left: auto; flex-shrink: 0; color: inherit; }
.notice-error { background: #f3e6e3; color: #ad7973; }
.confirm-dialog { width: min(420px, calc(100% - 34px)); max-height: calc(100dvh - 50px); padding: 29px; border: 1px solid #fff; border-radius: 24px; background: #f8f5fb; color: var(--ink); box-shadow: 0 30px 90px #69537738; overflow-y: auto; }
.confirm-dialog[open] { animation: dialog-appear .25s var(--ease); }
.confirm-dialog::backdrop { background: #6655722e; backdrop-filter: blur(6px); }
.confirm-dialog .round-icon { background: #ece4f1; margin-bottom: 19px; }
.confirm-dialog .subtle-label { font-size: 9px; }
.confirm-dialog h2 { color: var(--ink); font-size: 21px; font-weight: 450; letter-spacing: -.045em; margin-top: 8px; overflow-wrap: anywhere; }
.confirm-dialog p { color: var(--muted); font-size: 12px; line-height: 1.8; margin-top: 13px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 26px; }
.toast-container { position: fixed; bottom: 27px; right: 30px; display: grid; gap: 9px; max-width: min(370px, calc(100vw - 34px)); z-index: 60; }
.toast { display: flex; align-items: flex-start; gap: 10px; padding: 14px 17px; border: 1px solid #fff; border-radius: 15px; background: #fbf8fef5; color: #9271a1; box-shadow: 0 9px 35px #77568521; font-size: 11px; animation: toast-appear .35s var(--ease); overflow-wrap: anywhere; }
.toast > .icon { width: 16px; height: 16px; color: #b2b98a; margin-top: 1px; }
.toast.toast-error { color: #b7786e; }
.toast.toast-error > .icon { color: #c68d81; }
.noscript-message { padding: 20px; text-align: center; color: #8c6d98; }
.nav-scrim { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes page-appear { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
@keyframes dialog-appear { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes toast-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
* { scrollbar-width: thin; scrollbar-color: #c9bdd354 transparent; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { border-radius: 8px; background: #c9bdd36b; }
::-webkit-scrollbar-thumb:hover { background: #b5a4c18f; }
@media (min-width: 1650px) {
.app-shell { grid-template-columns: 423px minmax(0, 1fr); }
.workspace-drawer { grid-template-columns: minmax(0, 1fr) 174px; gap: 19px; padding-inline: 24px 21px; }
.nav-item { font-size: 11px; min-height: 40px; }
.sidebar-heading h2 { font-size: 23px; }
.context-rail { gap: 20px; }
.insight-card { min-height: 177px; padding: 17px; }
.insight-card .card-caption { font-size: 10px; }
.insight-card p { font-size: 10px; }
.collection-label { min-height: 72px; }
.collection-label strong { font-size: 11px; }
.collection-label span { font-size: 9px; }
.collection-art { height: 79px; }
.topbar { min-height: 79px; }
.page-content { padding: 15px 33px 30px; }
.metric-card { min-height: 169px; padding: 20px 23px; }
.card-caption h2 { font-size: 12px; }
.fleet-number { margin-block: 15px; }
.fleet-number > strong { font-size: 45px; }
.fleet-number > span, .metric-bottom, .signal-readouts small { font-size: 9px; }
.range-labels { font-size: 8px; }
.signal-readouts { margin-top: 20px; }
.resource-line > span { font-size: 12px; }
.resource-line > small { font-size: 8px; }
.resource-line { margin-top: 23px; }
.second-resource { margin-top: 17px; }
.section-heading h2 { font-size: 15px; }
.section-heading > div > span { font-size: 10px; }
.portfolio-section { margin-top: 31px; }
.portfolio-table th { font-size: 9px; }
.portfolio-table td { padding-block: 15px; font-size: 10px; }
.portfolio-table .badge { font-size: 9px; }
.portfolio-name { font-size: 11px; }
.overview-bottom { margin-top: 27px; }
.fleet-ring { width: 226px; }
.ring-center > strong { font-size: 51px; }
.ring-center > span, .ring-legend span { font-size: 10px; }
.ring-center > span:last-child, .ring-legend small { font-size: 8px; }
.ring-legend { max-width: 195px; gap: 25px; }
.note-row { padding-block: 18px; }
.note-row strong { font-size: 11px; }
.note-row small { font-size: 9px; }
.notes-footer { font-size: 10px; }
.machine-table th { font-size: 10px; }
.machine-table td { padding-block: 18px; font-size: 11px; }
.machine-name a { max-width: 260px; font-size: 12px; }
.machine-name small { font-size: 9px; }
}
@media (max-width: 1360px) {
.app-shell { grid-template-columns: 342px minmax(0, 1fr); width: calc(100% - 32px); height: calc(100dvh - 32px); margin: 16px auto; }
.workspace-drawer { grid-template-columns: minmax(0, 1fr) 142px; gap: 12px; padding-inline: 16px 14px; }
.sidebar-heading h2 { font-size: 19px; }
.brand-tag { display: none; }
.nav-item { font-size: 9px; gap: 8px; padding-inline: 9px; }
.insight-card { padding-inline: 12px; }
.insight-card .card-caption { font-size: 8px; }
.insight-card p { font-size: 8px; }
.collection-label { padding-inline: 12px; }
.collection-label span { font-size: 7px; }
.topbar { padding-inline: 20px; }
.page-content { padding-inline: 20px; }
.metric-card { padding: 15px; }
.card-caption h2 { font-size: 10px; }
.fleet-number > span { font-size: 7px; }
.fleet-number { gap: 10px; }
.metric-bottom { font-size: 7px; }
.overview-bottom { grid-template-columns: 1.15fr 1fr; }
.activity-panel, .notes-panel { padding-inline: 16px; }
.fleet-ring { width: 165px; }
.ring-center > strong { font-size: 37px; }
.ring-legend { gap: 18px; }
.ring-legend small { display: none; }
.note-row { padding-block: 11px; }
.note-row strong { font-size: 8px; }
.note-row small { font-size: 7px; }
.notes-footer { font-size: 7px; }
.create-layout { grid-template-columns: minmax(0, 1.7fr) minmax(195px, .85fr); gap: 13px; }
.form-section { padding: 19px; }
.create-summary { padding: 21px 17px; }
.size-preset { padding: 10px 9px; }
.size-preset span { font-size: 7px; }
.compact-metric { padding: 17px; gap: 11px; }
.health-layout { grid-template-columns: 1.2fr 1fr; gap: 13px; }
}
@media (max-width: 1150px) {
.app-shell { grid-template-columns: 200px minmax(0, 1fr); }
.workspace-drawer { display: flex; padding: 25px 17px; }
.sidebar { width: 100%; }
.context-rail { display: none; }
.brand-tag { display: inline; }
.nav-item { font-size: 10px; }
.sidebar-heading h2 { font-size: 21px; }
.overview-bottom { grid-template-columns: 1.2fr 1fr; }
.health-layout { grid-template-columns: 1.15fr 1fr; }
.diagnostics-panel, .install-panel, .server-info { padding: 19px; }
.create-layout { grid-template-columns: minmax(0, 1.7fr) minmax(208px, .9fr); }
.detail-heading { align-items: flex-start; }
.ssh-panel { align-items: flex-start; flex-direction: column; gap: 15px; }
.ssh-command { width: 100%; }
}
@media (max-width: 900px) {
.app-shell { grid-template-columns: 182px minmax(0, 1fr); }
.workspace-drawer { padding-inline: 13px; }
.brand-tag { display: none; }
.nav-item { font-size: 9px; }
.global-search { max-width: 185px; }
.global-search kbd { display: none; }
.topbar { padding-inline: 18px; gap: 6px; }
.workspace-crumb { font-size: 8px; gap: 6px; padding-inline: 10px; }
.page-content { padding-inline: 18px; }
.page-heading h1 { font-size: 24px; }
.date-label { font-size: 8px; }
.overview-metrics { grid-template-columns: 1fr 1fr; gap: 11px; }
.resources-metric { grid-column: 1 / -1; min-height: auto; display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; padding-block: 14px; }
.resources-metric .card-caption { grid-column: 1 / -1; }
.resources-metric .resource-line { grid-row: 2; margin-top: 13px; }
.resources-metric .resource-track { grid-row: 3; }
.resources-metric .second-resource { grid-column: 2; }
.overview-bottom { grid-template-columns: 1fr; }
.activity-body { justify-content: center; gap: 30px; }
.fleet-ring { width: 185px; }
.ring-legend { max-width: 165px; gap: 23px; }
.ring-legend small { display: block; }
.notes-panel { padding: 20px; }
.note-row { padding-block: 12px; }
.note-row strong { font-size: 10px; }
.note-row small { font-size: 8px; }
.notes-footer { font-size: 9px; }
.compact-metrics { gap: 9px; }
.compact-metric { padding: 15px; gap: 9px; }
.compact-metric > .round-icon { display: none; }
.compact-metric strong { font-size: 24px; }
.compact-metric span { font-size: 8px; }
.filter-tab { padding-inline: 10px; font-size: 9px; }
.fleet-toolbar { flex-wrap: wrap; gap: 11px; }
.health-layout, .network-layout { grid-template-columns: 1fr; }
.health-side { grid-template-columns: 1fr 1fr; gap: 13px; align-items: start; }
.fabric-diagram { padding-block: 10px; }
.network-map-panel .fabric-diagram { display: grid; grid-template-columns: 1fr 50px 1fr; justify-items: center; }
.network-map-panel .fabric-connector { height: 1px; width: 45px; }
.network-map-panel .fabric-branches, .network-map-panel .fabric-guests { display: none; }
.network-map-panel .fabric-diagram > .muted { grid-column: 1 / -1; margin-top: 25px; }
.network-map-panel .panel-footnote { text-align: center; }
.create-layout { grid-template-columns: 1fr; }
.create-summary { position: static; padding: 23px; }
.summary-art { display: none; }
.create-summary .detail-list { display: grid; grid-template-columns: 1fr 1fr; gap: 0 25px; margin-block: 15px; }
.create-summary > .button { max-width: 300px; margin: auto; display: flex; }
.summary-note { margin-top: 17px; }
.detail-grid { grid-template-columns: 1fr; }
.detail-heading { flex-direction: column; gap: 17px; }
.auth-window { min-height: 590px; }
.auth-story { padding: 29px; }
.auth-story-copy h1 { font-size: 37px; }
.auth-panel { padding-inline: 30px; }
}
@media (max-width: 700px) {
html, body { background: #e9e5ef; }
.app-shell { display: block; width: calc(100% - 20px); height: auto; min-height: calc(100dvh - 20px); margin: 10px; border-radius: 21px; overflow: clip; }
.workspace { min-height: calc(100dvh - 22px); }
.workspace-drawer { position: fixed; inset: 10px auto 10px 10px; width: 237px; z-index: 42; padding: 25px 20px; background: #f2edf7f5; border: 1px solid #fff; border-radius: 22px; backdrop-filter: blur(25px); transform: translateX(calc(-100% - 16px)); visibility: hidden; transition: transform .3s var(--ease), visibility .3s; box-shadow: 15px 0 45px #72508412; }
.app-shell.nav-open .workspace-drawer { visibility: visible; transform: translateX(0); }
.nav-scrim:not([hidden]) { display: block; position: fixed; inset: 0; z-index: 41; background: #80648e36; backdrop-filter: blur(3px); }
.brand-tag { display: inline; }
.nav-item { font-size: 11px; min-height: 41px; }
.sidebar-heading h2 { font-size: 23px; }
.sidebar-bottom { padding-top: 35px; }
.sidebar-bottom > .quiet-link { margin-top: 20px; }
.mobile-menu { display: inline-flex; width: 29px; height: 29px; flex: 0 0 auto; }
.topbar { min-height: 61px; padding-inline: 14px; gap: 7px; }
.workspace-crumb { background: transparent; padding-inline: 2px; font-size: 10px; box-shadow: none; }
.workspace-crumb > span:not(:last-child) { display: none; }
#breadcrumb { color: #92749d; }
.refresh-button { margin-right: 2px; }
.global-search { max-width: 215px; min-width: 80px; }
.global-search input { font-size: 9px; }
.topbar-create { flex-shrink: 0; margin-left: 1px; }
.page-content { flex: 1 0 auto; overflow: visible; padding: 13px 17px 24px; scrollbar-gutter: auto; }
.page-heading { gap: 12px; margin-bottom: 20px; }
.page-heading h1 { font-size: 26px; }
.page-heading .subtle-label { font-size: 9px; }
.date-label { display: none; }
.metric-card { min-height: 147px; padding: 17px; }
.card-caption h2 { font-size: 11px; }
.fleet-number > strong { font-size: 39px; }
.fleet-number > span { font-size: 8px; }
.metric-bottom { font-size: 8px; }
.resources-metric { min-height: 101px; }
.portfolio-section { margin-top: 25px; }
.portfolio-table { min-width: 540px; }
.overview-bottom { margin-top: 19px; }
.activity-panel, .notes-panel { padding: 20px; }
.activity-body { gap: 24px; }
.fleet-ring { width: 192px; }
.ring-center > strong { font-size: 43px; }
.ring-legend span { font-size: 10px; }
.ring-legend small { font-size: 8px; }
.page-footer { flex-direction: column; gap: 5px; font-size: 8px; margin-top: 20px; }
.compact-metrics { margin-bottom: 21px; gap: 8px; }
.compact-metric { min-height: 83px; padding: 14px; }
.compact-metric strong { font-size: 26px; }
.compact-metric .metric-word { font-size: 17px; }
.compact-metric span { font-size: 8px; }
.fleet-toolbar { margin-bottom: 13px; }
.fleet-list-panel { padding-inline: 11px; }
.filter-tabs { width: 100%; justify-content: space-between; gap: 0; }
.filter-tab { flex: 1; font-size: 9px; padding-inline: 9px; }
.fleet-toolbar > .select-wrap { margin-left: auto; }
.list-search-row { gap: 8px; }
.search-field input { font-size: 9px; }
.list-search-row > span { font-size: 8px; }
.list-footer { font-size: 8px; }
.list-footer > span:last-child { display: none; }
.bulk-controls { flex-wrap: wrap; gap: 7px; }
.bulk-controls > span { font-size: 9px; }
.bulk-controls select { font-size: 8px; }
.bulk-controls .button { font-size: 9px; }
.health-side { grid-template-columns: 1fr; }
.health-layout { gap: 13px; }
.install-panel, .server-info, .diagnostics-panel { padding: 22px; }
.diagnostic-item p { font-size: 9px; }
.diagnostic-item h3 { font-size: 11px; }
.install-panel > p { font-size: 11px; }
.install-panel .toggle-field strong { font-size: 10px; }
.server-info .detail-list > div { flex-direction: row; }
.server-info .detail-list dd { max-width: 65%; text-align: right; }
.form-section { padding: 21px; }
.create-summary { padding: 23px; }
.size-preset { padding: 12px; }
.size-preset span { font-size: 8px; }
.field input { font-size: 12px; }
.detail-delete-row { align-items: flex-start; }
.detail-delete-row > span { max-width: 170px; }
.ports-panel > .section-heading, .log-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child { flex: 1; }
.auth-screen { padding: 20px; }
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
.auth-story { min-height: 240px; padding: 26px; border-right: 0; }
.auth-story-copy { margin-top: 27px; }
.auth-story-copy > .subtle-label { font-size: 8px; }
.auth-story-copy h1 { font-size: 34px; margin-top: 8px; }
.auth-story-copy p { display: none; }
.auth-sculpture { position: absolute; width: 130px; height: 150px; min-height: 0; right: -2px; bottom: 23px; opacity: .65; transform: scale(.62); transform-origin: right bottom; }
.sculpture-caption { display: none; }
.auth-story-footer { margin-top: 19px; font-size: 7px; }
.auth-panel { padding: 31px 28px 60px; }
.auth-lock { display: none; }
.auth-panel h2 { font-size: 25px; }
.auth-panel form { margin-top: 22px; }
.auth-help { bottom: 23px; }
.toast-container { bottom: 17px; right: 17px; }
.notice { align-items: flex-start; font-size: 9px; }
}
@media (max-width: 390px) {
.topbar { gap: 3px; padding-inline: 11px; }
.workspace-crumb { font-size: 9px; }
.global-search input { font-size: 8px; }
.page-content { padding-inline: 13px; }
.page-heading h1 { font-size: 23px; }
.page-heading > .button { padding-inline: 12px; font-size: 9px; }
.page-heading > .tag { font-size: 8px; padding-inline: 8px; }
.card-caption h2 { font-size: 10px; }
.metric-card { padding: 14px; }
.fleet-number > span { font-size: 7px; }
.metric-bottom { font-size: 7px; }
.signal-readouts small { font-size: 7px; }
.range-labels { font-size: 6px; }
.activity-body { gap: 12px; }
.fleet-ring { width: 160px; }
.ring-center > strong { font-size: 36px; }
.ring-legend span { font-size: 9px; }
.ring-legend small { font-size: 7px; }
.form-section { padding: 17px; }
.size-presets { gap: 7px; }
.size-preset { padding: 10px; }
.size-preset span { font-size: 7px; }
.form-grid { gap: 11px; }
.field > span:first-child { font-size: 9px; }
.section-heading h2 { font-size: 12px; }
.log-panel .section-heading { flex-wrap: wrap; }
.auth-screen { padding: 13px; }
}
@media (max-height: 820px) and (min-width: 1151px) {
.context-rail { gap: 13px; padding-top: 46px; }
.insight-card { min-height: 150px; }
.collection-art { height: 53px; }
.collection-label { min-height: 60px; }
.rail-footnote { display: none; }
.sidebar-bottom { padding-top: 30px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
+92
View File
@@ -0,0 +1,92 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+205
View File
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#e8e7ed" />
<meta name="color-scheme" content="light" />
<title>UVM Control</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='10' fill='%23eee7ac'/%3E%3Cpath d='M9 11v7a7 7 0 0 0 14 0v-7' fill='none' stroke='%234b4854' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<link rel="preload" href="/dashboard/static/fonts/manrope-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css?v=2" />
<script src="/dashboard/static/js/dashboard.js?v=2" defer></script>
</head>
<body>
<svg class="icon-sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="6" height="6" rx="1.7"/><rect x="14.5" y="3.5" width="6" height="6" rx="1.7"/><rect x="3.5" y="14.5" width="6" height="6" rx="1.7"/><rect x="14.5" y="14.5" width="6" height="6" rx="1.7"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01M15 7h3M15 17h3"/></symbol>
<symbol id="i-network" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.5"/><rect x="2" y="16" width="6" height="5" rx="1.5"/><rect x="16" y="16" width="6" height="5" rx="1.5"/><path d="M12 8v4M5 16v-4h14v4"/></symbol>
<symbol id="i-heart" viewBox="0 0 24 24"><path d="M20.8 5.8a5.2 5.2 0 0 0-7.4 0L12 7.2l-1.4-1.4a5.2 5.2 0 0 0-7.4 7.4L12 22l8.8-8.8a5.2 5.2 0 0 0 0-7.4Z" transform="translate(0 -2) scale(1 0.95)"/><path d="M5 11h3l2-3 3 7 2-4h4"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5 5 5-5 5"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M19 12H5m5-5-5 5 5 5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 10a8 8 0 0 0-14-4L3 9m0-6v6h6M4 14a8 8 0 0 0 14 4l3-3m-6 0h6v6"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M12 5v15M12 5C8 2 3 4 3 4v15s5-2 9 1c4-3 9-1 9-1V4s-5-2-9 1Z"/></symbol>
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M10 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5M8 12h13m-5-5 5 5-5 5"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></symbol>
<symbol id="i-spark" viewBox="0 0 24 24"><path d="m12 3 2.7 6.3L21 12l-6.3 2.7L12 21l-2.7-6.3L3 12l6.3-2.7L12 3Z"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"/><path d="m8 12 3 3 5-6"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3"/></symbol>
<symbol id="i-memory" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M7 10h3v4H7zm7 0h3v4h-3zM7 18v3m5-3v3m5-3v3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 4 12 8-12 8V4Z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 9 3 3-3 3m6 0h4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7h.01"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M4 16v4h16v-4"/></symbol>
</svg>
<section id="auth-screen" class="auth-screen">
<div class="auth-window">
<div class="auth-story">
<a href="#overview" class="brand" aria-label="UVM Control"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="auth-story-copy"><span class="subtle-label">A little space for big ideas</span><h1>Your compute.<br />A little calmer.</h1><p>A considered workspace for your machines,<br />your network, and everything in between.</p></div>
<div class="auth-sculpture" aria-hidden="true">
<div class="sculpture-orbit orbit-outer"></div><div class="sculpture-orbit orbit-inner"></div>
<div class="sculpture-card sculpture-back"><span></span><i></i><i></i><i></i></div>
<div class="sculpture-card sculpture-middle"><span></span><i></i><i></i><i></i></div>
<div class="sculpture-card sculpture-front"><svg class="icon"><use href="#i-server"/></svg><span>Room to create.</span><div class="sculpture-lights"><i></i><i></i><i></i></div></div>
<div class="sculpture-caption"><span class="dot dot-yellow"></span>Small footprint. Your possibilities.</div>
</div>
<span class="auth-story-footer">MicroVMs, thoughtfully managed.</span>
</div>
<main class="auth-panel">
<div class="auth-panel-inner">
<span class="round-icon auth-lock"><svg class="icon"><use href="#i-lock"/></svg></span>
<span class="subtle-label">Your local workspace</span>
<h2>Welcome to UVM</h2>
<p>Bring your machines into view.<br />Connect with your management token to begin.</p>
<form id="auth-form">
<label class="field" for="token-input"><span>Management token</span><input id="token-input" name="token" type="password" placeholder="Enter your API token" autocomplete="off" spellcheck="false" required aria-describedby="auth-error" /></label>
<p id="auth-error" class="form-error" role="alert"></p>
<button id="auth-btn" class="button button-primary button-wide" type="submit"><span>Open workspace</span><svg class="icon"><use href="#i-arrow"/></svg></button>
</form>
<p class="auth-note"><svg class="icon"><use href="#i-shield"/></svg>Saved only for this browser session.</p>
</div>
<a class="quiet-link auth-help" href="/docs" target="_blank" rel="noopener">Explore the API <svg class="icon"><use href="#i-arrow"/></svg></a>
</main>
</div>
</section>
<section id="main-app" class="app-shell" hidden>
<button id="nav-scrim" class="nav-scrim" aria-label="Close navigation" hidden></button>
<aside id="workspace-nav" class="workspace-drawer" aria-label="Workspace navigation">
<div class="sidebar">
<a class="brand" href="#overview" aria-label="UVM Control overview"><span class="brand-flower" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>uvm<span class="brand-period">.</span></span><span class="brand-tag">workspace</span></a>
<div class="sidebar-heading"><span class="sidebar-heading-mark" aria-hidden="true">↳</span><h2><span id="sidebar-title">Management</span><span id="sidebar-subtitle">Overview</span></h2></div>
<button class="sidebar-search" type="button" data-action="search"><svg class="icon"><use href="#i-search"/></svg><span>Find in workspace</span><kbd>/</kbd></button>
<nav class="primary-nav" aria-label="Primary">
<a class="nav-item" href="#overview" data-nav="overview"><svg class="icon"><use href="#i-grid"/></svg><span>Overview</span><span class="nav-indicator"></span></a>
<a class="nav-item" href="#vms" data-nav="vms"><svg class="icon"><use href="#i-server"/></svg><span>Machines</span><span class="nav-count" id="nav-vm-count">—</span></a>
<a class="nav-item" href="#network" data-nav="network"><svg class="icon"><use href="#i-network"/></svg><span>Network & ports</span><span class="nav-indicator"></span></a>
<a class="nav-item" href="#system" data-nav="system"><svg class="icon"><use href="#i-heart"/></svg><span>Host health</span><span class="nav-health-dot" id="nav-health-dot"></span></a>
<a class="nav-item nav-create" href="#create" data-nav="create"><svg class="icon"><use href="#i-plus"/></svg><span>New machine</span><svg class="icon nav-end-icon"><use href="#i-plus"/></svg></a>
</nav>
<div class="sidebar-secondary"><a class="nav-item" href="/docs" target="_blank" rel="noopener"><svg class="icon"><use href="#i-book"/></svg><span>API reference</span><svg class="icon nav-end-icon"><use href="#i-arrow"/></svg></a></div>
<div class="sidebar-bottom">
<div class="workspace-identity"><span class="workspace-avatar">u</span><div><strong>Local workspace</strong><span><i id="connection-dot" class="dot"></i><span id="connection-label">Connecting</span></span></div></div>
<button id="logout-btn" class="quiet-link" type="button"><svg class="icon"><use href="#i-logout"/></svg>End session</button>
</div>
</div>
<div class="context-rail" aria-label="Workspace at a glance">
<a href="#system" class="insight-card">
<div class="card-caption"><span>Workspace insights</span><svg class="icon"><use href="#i-spark"/></svg></div>
<p id="insight-copy">A fresh perspective on your local compute.</p>
<div class="insight-matrix" aria-hidden="true"><span></span></div>
<span class="insight-footer"><span class="dot dot-yellow"></span><span id="insight-footer">Looking at the details</span></span>
</a>
<a href="#vms" class="collection collection-machines">
<div class="collection-art machine-art" aria-hidden="true"><div class="paper-machine back-paper"><span></span><i></i><i></i></div><div class="paper-machine front-paper"><svg class="icon"><use href="#i-server"/></svg><i></i><i></i><span></span></div></div>
<div class="collection-label"><div><strong>Machines</strong><span id="rail-machines">Your private fleet</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
</a>
<a href="#network" class="collection collection-network">
<div class="collection-art network-art" aria-hidden="true"><div class="network-paper"><span class="network-line"></span><i></i><i></i><i></i><svg class="icon"><use href="#i-network"/></svg></div></div>
<div class="collection-label"><div><strong>Network</strong><span id="rail-network">Connections, considered</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
</a>
<a href="#system" class="collection collection-simple"><div class="collection-label"><div><strong>Host health</strong><span id="rail-health">Checking the essentials</span></div><svg class="icon"><use href="#i-chevron"/></svg></div></a>
<a href="#create" class="collection collection-simple collection-new"><div class="collection-label"><div><strong>Something new?</strong><span>Make room for an idea</span></div><svg class="icon"><use href="#i-plus"/></svg></div></a>
<span class="rail-footnote">A small cloud.<br />Entirely yours.</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<button id="nav-toggle" class="icon-button mobile-menu" type="button" aria-label="Open navigation" aria-expanded="false" aria-controls="workspace-nav"><svg class="icon"><use href="#i-menu"/></svg></button>
<div class="workspace-crumb"><span class="dot dot-yellow"></span><span>Workspace</span><span class="crumb-divider">/</span><span id="breadcrumb">Overview</span></div>
<button id="refresh-btn" class="icon-button refresh-button" type="button" aria-label="Refresh workspace" title="Refresh workspace"><svg class="icon"><use href="#i-refresh"/></svg></button>
<div class="global-search">
<svg class="icon"><use href="#i-search"/></svg><input id="global-search" type="search" placeholder="Find a machine or a page" autocomplete="off" role="combobox" aria-label="Search workspace" aria-autocomplete="list" aria-expanded="false" aria-controls="search-results" /><kbd>⌘ K</kbd>
<div id="search-results" class="search-results" role="listbox" aria-label="Search results" hidden></div>
</div>
<a href="#create" class="icon-button topbar-create" aria-label="Create a new machine" title="Create a new machine"><svg class="icon"><use href="#i-plus"/></svg></a>
</header>
<main id="main-content" class="page-content">
<div id="connection-banner" class="notice notice-error" role="status" hidden><svg class="icon"><use href="#i-info"/></svg><span id="connection-message"></span><button class="quiet-link" type="button" data-action="refresh">Try again</button></div>
<section id="page-overview" class="page" aria-labelledby="overview-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">The bigger picture</span><h1 id="overview-heading" tabindex="-1">Workspace overview<span class="heading-period">.</span></h1></div><span id="today-label" class="date-label"></span></div>
<div class="overview-metrics">
<article class="metric-card fleet-metric"><div class="card-caption"><h2>Your machine fleet</h2><a href="#vms" class="mini-link" aria-label="View all machines"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="fleet-number"><strong id="metric-total">—</strong><span>little machines,<br />endless possibilities</span></div><div class="metric-bottom"><span class="status-inline"><i class="dot dot-yellow"></i><span id="metric-running">Waiting for the fleet</span></span><span id="metric-stopped" class="muted">—</span></div></article>
<article class="metric-card"><div class="card-caption"><h2>Host signals</h2><a href="#system" class="mini-link" aria-label="View host signals"><svg class="icon"><use href="#i-chevron"/></svg></a></div><div class="signal-readouts"><div><span class="dot dot-sage"></span><strong id="checks-ready">—</strong><small>Ready</small></div><div><span class="dot dot-peach"></span><strong id="checks-attention">—</strong><small>Attention</small></div><div><span class="dot dot-lilac"></span><strong id="checks-total">—</strong><small>Checks</small></div></div><div class="segmented-track" aria-hidden="true"><span id="checks-ready-bar"></span><span id="checks-attention-bar"></span></div><div class="range-labels"><span>Host readiness</span><span id="checks-summary">Taking a look</span></div></article>
<article class="metric-card resources-metric"><div class="card-caption"><h2>Resource allocation</h2><svg class="icon caption-icon"><use href="#i-cpu"/></svg></div><div class="resource-line"><span id="metric-cpu">— vCPU</span><small id="cpu-active-label">Allocated</small></div><div class="resource-track"><span id="cpu-allocation-bar"></span></div><div class="resource-line second-resource"><span id="metric-ram">— MiB</span><small id="ram-active-label">Allocated</small></div><div class="resource-track memory-track"><span id="ram-allocation-bar"></span></div></article>
</div>
<section class="portfolio-section" aria-labelledby="portfolio-heading"><header class="section-heading"><div><h2 id="portfolio-heading">Machine portfolio</h2><span>A place for every workload.</span></div><a class="quiet-link" href="#vms">View all <svg class="icon"><use href="#i-arrow"/></svg></a></header><div id="overview-machines" class="portfolio-content"><div class="loading-state">Bringing your machines into view…</div></div></section>
<div class="overview-bottom">
<article class="activity-panel soft-panel"><header class="section-heading"><div><h2>Fleet activity</h2><span>A snapshot of your machine states.</span></div><svg class="icon caption-icon"><use href="#i-grid"/></svg></header><div class="activity-body"><div class="fleet-ring" role="img" id="fleet-ring" aria-label="Waiting for machine activity"><svg viewBox="0 0 200 200" aria-hidden="true"><circle class="ring-track" cx="100" cy="100" r="82"/><circle id="ring-running" class="ring-segment ring-running" cx="100" cy="100" r="82" pathLength="100"/><circle id="ring-stopped" class="ring-segment ring-stopped" cx="100" cy="100" r="82" pathLength="100"/><circle id="ring-other" class="ring-segment ring-other" cx="100" cy="100" r="82" pathLength="100"/></svg><div class="ring-center"><span>Running now</span><strong id="ring-percent">—<small>%</small></strong><span id="ring-total">Your fleet, in balance</span></div></div><div class="ring-legend"><div><i class="dot dot-yellow"></i><span>Running<small>Doing their thing</small></span><strong id="legend-running">—</strong></div><div><i class="dot dot-peach"></i><span>Stopped<small>Taking a breather</small></span><strong id="legend-stopped">—</strong></div><div><i class="dot dot-lilac"></i><span>Other states<small>A closer look</small></span><strong id="legend-other">—</strong></div></div></div></article>
<article class="notes-panel soft-panel"><header class="section-heading"><div><h2>Worth a little attention</h2><span>The useful details, together.</span></div><svg class="icon caption-icon"><use href="#i-spark"/></svg></header><div id="workspace-notes" class="workspace-notes"></div><a href="#system" class="notes-footer quiet-link">A closer look at your host <svg class="icon"><use href="#i-arrow"/></svg></a></article>
</div>
<footer class="page-footer"><span><i class="dot" id="footer-dot"></i>Live from your local control plane</span><span id="last-updated">Waiting for an update</span></footer>
</section>
<section id="page-vms" class="page" aria-labelledby="machines-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">A collection of possibilities</span><h1 id="machines-heading" tabindex="-1">Your machines<span class="heading-period">.</span></h1></div><a class="button button-primary" href="#create"><svg class="icon"><use href="#i-plus"/></svg>New machine</a></div>
<div id="fleet-summary" class="compact-metrics"></div>
<div class="fleet-toolbar"><div class="filter-tabs" role="group" aria-label="Filter machines by state"><button type="button" class="filter-tab active" data-filter="all" aria-pressed="true">All machines</button><button type="button" class="filter-tab" data-filter="running" aria-pressed="false">Running</button><button type="button" class="filter-tab" data-filter="stopped" aria-pressed="false">Stopped</button><button type="button" class="filter-tab" data-filter="attention" aria-pressed="false">Attention</button></div><label class="select-wrap"><span class="sr-only">Sort machines</span><select id="vm-sort"><option value="recent">Newest first</option><option value="name">Name A–Z</option><option value="status">Machine state</option></select></label></div>
<div class="fleet-list-panel"><div class="list-search-row"><label class="search-field"><svg class="icon"><use href="#i-search"/></svg><input id="vm-search" type="search" placeholder="Find by name, ID, or address" aria-label="Search machines" /></label><span id="visible-count" class="muted"></span></div><div id="bulk-controls" class="bulk-controls" hidden><span id="bulk-count"></span><label class="select-wrap"><span class="sr-only">Bulk action</span><select id="bulk-action"><option value="start">Start machines</option><option value="stop">Stop machines</option><option value="restart">Restart machines</option><option value="ports-sync">Sync port policies</option><option value="delete">Delete machines</option></select></label><button id="bulk-run-btn" class="button button-small button-primary" type="button">Apply</button><button class="icon-button" type="button" data-action="clear-selection" aria-label="Clear selection"><svg class="icon"><use href="#i-close"/></svg></button></div><div id="machine-table-wrap" class="table-scroll"><table class="machine-table"><thead><tr><th class="check-cell"><input id="vm-select-all" type="checkbox" aria-label="Select all visible machines" /></th><th>Machine</th><th>State</th><th>Allocation</th><th>Ingress</th><th>Policy</th><th class="row-controls-heading"><span class="sr-only">Actions</span></th></tr></thead><tbody id="machine-rows"></tbody></table></div><div id="machines-empty" hidden></div><div class="list-footer"><span id="fleet-list-caption">Your private machine registry.</span><span>Made for a small footprint.</span></div></div>
<div id="bulk-results" class="operation-results" aria-live="polite" hidden></div>
</section>
<section id="page-network" class="page" aria-labelledby="network-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Good connections start here</span><h1 id="network-heading" tabindex="-1">Network & ports<span class="heading-period">.</span></h1></div><span class="tag"><svg class="icon"><use href="#i-network"/></svg>Private IPv4 · TCP</span></div>
<div id="network-summary" class="compact-metrics"></div>
<div class="network-layout"><article class="soft-panel network-map-panel"><header class="section-heading"><div><h2>Your private fabric</h2><span>One host. Room to connect.</span></div></header><div class="fabric-diagram"><div class="fabric-node"><span class="round-icon"><svg class="icon"><use href="#i-server"/></svg></span><strong>Local host</strong><small>UVM control plane</small></div><div class="fabric-connector"></div><div class="fabric-node fabric-gateway"><span class="round-icon"><svg class="icon"><use href="#i-network"/></svg></span><strong id="network-gateway">No gateway yet</strong><small>Private gateway</small></div><div class="fabric-branches" aria-hidden="true"><span></span><span></span><span></span></div><div class="fabric-guests" aria-hidden="true"><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg><svg class="icon"><use href="#i-server"/></svg></div><span id="network-machine-count" class="muted">Waiting for your fleet</span></div><p class="panel-footnote">Port rules allow private guest TCP traffic. HTTP and HTTPS are descriptive labels for TCP services.</p></article><article class="soft-panel network-policies"><header class="section-heading"><div><h2>Ports, with purpose</h2><span>Choose a machine to manage its rules.</span></div><svg class="icon caption-icon"><use href="#i-shield"/></svg></header><div id="network-policies"></div></article></div>
</section>
<section id="page-vm-detail" class="page" aria-labelledby="detail-heading" hidden>
<a class="quiet-link back-link" href="#vms"><svg class="icon"><use href="#i-back"/></svg>Back to machines</a>
<div id="detail-loading" class="loading-state">Getting to know this machine…</div>
<div id="detail-content" hidden>
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div>
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article>
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
<div class="detail-delete-row"><span>Every machine has its own private disk.</span><button class="quiet-link danger-text" type="button" data-action="delete-current"><svg class="icon"><use href="#i-trash"/></svg>Delete machine</button></div>
</div>
</section>
<section id="page-create" class="page" aria-labelledby="create-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div>
<form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
</form>
</section>
<section id="page-system" class="page" aria-labelledby="system-heading" hidden>
<div class="page-heading"><div><span class="subtle-label">Behind every little machine</span><h1 id="system-heading" tabindex="-1">A look at your host<span class="heading-period">.</span></h1></div><button id="diagnostics-refresh-btn" class="button" type="button" data-action="diagnostics"><svg class="icon"><use href="#i-refresh"/></svg>Run checks</button></div>
<div id="health-summary" class="compact-metrics"></div>
<div class="health-layout"><article class="soft-panel diagnostics-panel"><header class="section-heading"><div><h2>The essentials, checked</h2><span>Readiness checks from your local host.</span></div><span id="diagnostics-count" class="tag">Checking</span></header><div id="diagnostics-list" class="diagnostics-list"><div class="loading-state">Checking in with your host…</div></div></article><div class="health-side"><article class="soft-panel install-panel"><span class="round-icon"><svg class="icon"><use href="#i-download"/></svg></span><h2>A foundation for your fleet</h2><p>Install the Firecracker binary, guest kernel, and root filesystem, ready for your next machine.</p><div class="install-assets"><span><svg class="icon"><use href="#i-cpu"/></svg>Firecracker</span><span><svg class="icon"><use href="#i-terminal"/></svg>Guest kernel</span><span><svg class="icon"><use href="#i-server"/></svg>Root filesystem</span></div><label class="toggle-field"><span><strong>Refresh existing assets</strong><small>Reinstall with force enabled.</small></span><input id="install-force" type="checkbox" /><span class="toggle-track" aria-hidden="true"></span></label><button id="install-btn" class="button button-primary button-wide" type="button" data-action="install"><span>Install assets</span><svg class="icon"><use href="#i-arrow"/></svg></button><pre id="install-result" class="install-result" role="status" hidden></pre></article><article class="soft-panel server-info"><header class="section-heading"><h2>Close to home</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl class="detail-list"><div><dt>API origin</dt><dd id="info-api-url"></dd></div><div><dt>Last host check</dt><dd id="info-observed">Not checked yet</dd></div><div><dt>Connection</dt><dd>Session token</dd></div></dl><a href="/docs" target="_blank" rel="noopener" class="quiet-link">Open API reference <svg class="icon"><use href="#i-arrow"/></svg></a></article></div></div>
</section>
</main>
</div>
</section>
<div id="action-menu" class="action-menu" role="menu" aria-label="Machine actions" hidden></div>
<div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="false"></div>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-body"><form method="dialog"><span class="round-icon" id="confirm-icon"><svg class="icon"><use href="#i-info"/></svg></span><span class="subtle-label">A moment before we continue</span><h2 id="confirm-title"></h2><p id="confirm-body"></p><div class="dialog-actions"><button class="button button-quiet" value="cancel" autofocus>Cancel</button><button id="confirm-ok" class="button button-primary" value="confirm">Confirm</button></div></form></dialog>
<noscript><p class="noscript-message">Enable JavaScript to connect to your UVM workspace.</p></noscript>
</body>
</html>
File diff suppressed because it is too large Load Diff
+45
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import re
import shutil
import time
@@ -39,6 +40,15 @@ class OperationResult:
error: str | None = None
@dataclass(frozen=True, slots=True)
class LogTail:
content: str
truncated: bool
_MAX_LOG_TAIL_BYTES = 262_144
class LifecycleService:
"""All mutating entry points use operation lock -> short state transaction."""
@@ -415,6 +425,41 @@ class LifecycleService:
self._validate_paths(vm)
return Path(vm.log)
def read_log_tail(self, identifier: str, *, tail: int) -> LogTail:
"""Read a bounded tail from a validated VM log without following it."""
if type(tail) is not int or tail < 0:
raise ValidationError("log tail must be a non-negative integer")
path = self.log_path(identifier)
try:
with path.open("rb") as stream:
end = stream.seek(0, os.SEEK_END)
if tail == 0:
return LogTail(content="", truncated=False)
start = max(0, end - _MAX_LOG_TAIL_BYTES)
previous = b""
if start:
stream.seek(start - 1)
previous = stream.read(1)
stream.seek(start)
content = stream.read(end - start)
except OSError as error:
raise UvmError("could not read VM log") from error
truncated = False
if start and previous != b"\n":
newline = content.find(b"\n")
if newline == -1:
truncated = True
else:
content = content[newline + 1:]
lines = content.splitlines(keepends=True)
if start and len(lines) < tail:
truncated = True
return LogTail(
content=b"".join(lines[-tail:]).decode("utf-8", errors="replace"),
truncated=truncated,
)
def diagnostics(self) -> list[dict]:
checks = [("root", require_root), ("kvm", check_kvm),
("Firecracker", self._verify_firecracker),
+17
View File
@@ -0,0 +1,17 @@
"""Dashboard frontend routes."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
@router.get("", response_class=HTMLResponse)
def get_dashboard() -> str:
return _DASHBOARD_HTML.read_text(encoding="utf-8")
+18
View File
@@ -0,0 +1,18 @@
"""Token-protected host diagnostics endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..api_models import DiagnosticResponse
from ..app import Application
from .dependencies import get_authorized_application
router = APIRouter(tags=["diagnostics"])
@router.get("/diagnostics", response_model=list[DiagnosticResponse])
def diagnostics(
application: Application = Depends(get_authorized_application),
) -> list[DiagnosticResponse]:
return [DiagnosticResponse(**check) for check in application.lifecycle.diagnostics()]
+129 -10
View File
@@ -2,18 +2,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
from ..api_models import (
DestroyResponse,
PortSyncResponse,
PortUpdateRequest,
VmActionResultResponse,
VmBulkActionRequest,
VmCreateRequest,
VmLogResponse,
VmResponse,
vm_response,
)
from ..app import Application
from ..domain import PortRule, VmSpec
from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username
from ..domain import PortRule, VmRecord, VmSpec
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_timeout,
parse_username,
)
from .dependencies import get_authorized_application
router = APIRouter(prefix="/vms", tags=["vms"])
def _mutation_policy_status(vm: VmRecord) -> str:
"""Return the policy state that follows a successful lifecycle mutation."""
if vm.ports is None:
return "unmanaged"
if vm.operation is not None:
return "unknown"
if vm.status == "running":
return "enforced"
if vm.status == "stopped":
return "inactive"
return "unknown"
@router.get("", response_model=list[VmResponse])
def list_vms(
application: Application = Depends(get_authorized_application),
@@ -47,29 +78,117 @@ def create_vm(
ports=ports,
)
)
policy_status = "unmanaged" if vm.ports is None else "unknown"
if vm.ports is not None and vm.status == "running" and vm.operation is None:
policy_status = "enforced"
return vm_response(vm, policy_status=policy_status)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/actions", response_model=list[VmActionResultResponse])
def run_bulk_action(
request: VmBulkActionRequest,
application: Application = Depends(get_authorized_application),
) -> list[VmActionResultResponse]:
timeout = parse_timeout(str(request.timeout)) if request.timeout is not None else None
targets = application.lifecycle.resolve_targets(request.identifiers, all_vms=request.all_vms)
results = application.lifecycle.run_many(
request.action,
[vm.id for vm in targets],
timeout=timeout,
)
return [
VmActionResultResponse(
id=result.id,
action=result.action,
outcome=result.outcome,
error=result.error,
)
for result in results
]
@router.post("/{vm_id}/start", response_model=VmResponse)
def start_vm(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = application.lifecycle.start(vm_id)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/restart", response_model=VmResponse)
def restart_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = (
application.lifecycle.restart(vm_id)
if timeout is None
else application.lifecycle.restart(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/stop", response_model=VmResponse)
def stop_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive")
vm = (
application.lifecycle.stop(vm_id)
if timeout is None
else application.lifecycle.stop(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> DestroyResponse:
vm = application.lifecycle.destroy(vm_id)
vm = (
application.lifecycle.destroy(vm_id)
if timeout is None
else application.lifecycle.destroy(vm_id, timeout=timeout)
)
return DestroyResponse(id=vm.id)
@router.post("/{vm_id}/ports", response_model=VmResponse)
def update_ports(
vm_id: str,
request: PortUpdateRequest,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports])
vm = application.lifecycle.set_ports(vm_id, ports, mode=request.mode)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/ports/sync", response_model=PortSyncResponse)
def sync_ports(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> PortSyncResponse:
vm = application.lifecycle.sync_ports(vm_id)
return PortSyncResponse(
id=vm.id,
policy_status=_mutation_policy_status(vm),
status=vm.status,
)
@router.get("/{identifier}/logs", response_model=VmLogResponse)
def get_vm_logs(
identifier: str,
tail: int = Query(default=100, ge=0, le=1000),
application: Application = Depends(get_authorized_application),
) -> VmLogResponse:
log = application.lifecycle.read_log_tail(identifier, tail=tail)
return VmLogResponse(content=log.content, truncated=log.truncated)
@router.get("/{identifier}", response_model=VmResponse)
def get_vm(
identifier: str,
+14 -1
View File
@@ -27,7 +27,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non
try:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
except ModuleNotFoundError as error:
raise UvmError(
"FastAPI server support is not installed. Install this project with its dependencies."
@@ -36,6 +37,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non
from .routers.health import router as health_router
from .routers.installation import router as installation_router
from .routers.vms import router as vms_router
from .routers.diagnostics import router as diagnostics_router
from .routers.dashboard import router as dashboard_router
api = FastAPI(
title="uvm",
@@ -54,6 +57,16 @@ def create_api(application: Application | None = None, *, host: str | None = Non
api.include_router(health_router)
api.include_router(installation_router)
api.include_router(vms_router)
api.include_router(diagnostics_router)
static_dir = Path(__file__).parent / "dashboard" / "static"
api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static")
api.include_router(dashboard_router, prefix="/dashboard")
@api.get("/")
async def root() -> Any:
return RedirectResponse(url="/dashboard")
return api