diff --git a/AGENTS.md b/AGENTS.md index 08679cd..b0c39aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,16 +10,17 @@ management, LVM thin storage, and FD.io VPP networking. - UI sources live under `ui/src`; `make ui-build` typechecks with `tsc` and bundles with esbuild into `internal/webui/static/build`, which is committed and embedded by `go:embed static`. Edit sources, not generated bundles. `make build` builds the UI and Go binary; `make ui-watch` rebuilds on change. SQL migrations (`internal/store/migrations`) also use `go:embed`; keep runtime assets inside those trees and avoid CDNs. noVNC v1.7.0 is vendored under `internal/webui/static/novnc` (MPL 2.0) and is imported as an external browser module by the console bundle; do not replace it with a CDN. The UI is fully Preact/TypeScript: the console page (`ui/src/console`) and inventory tabs (`ui/src/inventory/{vms,disks,media,networks,vnfs}`) are components, while `ui/src/inventory/app.ts` is the typed tab/readiness shell. The shell and tab islands communicate through `rookery:*` window CustomEvents (`vms-changed`, `add-vm`, `readiness-changed`, `networks-changed`, `interfaces-data`, `interfaces-changed`, `add-network`, `open-tab`, `disks-data`, `disks-changed`, `add-disk`, `media-data`, `media-changed`, `add-media`, `vnfs-data`, `vnfs-changed`, `vpp-ready`, `add-vnf`, `refresh-networks`, `tab-changed`); keep these bridges in sync. Inventory routing is path-based (`ui/src/inventory/router.ts`): tabs are `/vms`, `/disks`, `/media`, `/networks`, `/vnfs`; dialogs use deep links such as `/vms/new`, `/vms/{uuid}/edit`, `/interfaces/{id}/acls`, and `/vnfs/{id}/leases`. `internal/webui.Handler` must keep serving `index.html` for HTML navigations that do not match an embedded asset, or refreshing a deep link returns 404. - Inventory UI HTTP calls go through `ui/src/shared/api.ts`. It shares in-flight GETs, retries transient failures after 1s/2s/4s/10s, then fails with an `APIError` naming the method and path. Mutations only retry `429`/`503` to avoid duplicate creates. Do not add unbounded retries or unguarded polling loops. - VM IDs are UUIDs (`internal/store/migrations/0004_vm_uuid.sql`). Use UUID paths/validation consistently; disk and media IDs remain integers. -- `POST /api/vms` creates the VM and starts it immediately; stopping a VM is always an explicit `/stop`. When the QEMU start fails, creation rolls back the VM record (and stops any partial QEMU process) and returns the start error, so callers never see a half-created VM. `vms.desired_state` (`0038_vm_desired_state.sql`) records whether the VM should be running: start sets `running`, stop sets `stopped`, and creation sets `running` after a successful start. On API startup (and after a VPP restart) `internal/api.reconcileInfrastructure` calls `autostartVMs`, which starts every VM whose desired state is `running` and whose QEMU process is gone; explicitly stopped VMs stay down. Autostart runs after the VPP resources are reconciled so vhost-user sockets exist, holds the shared run mutex, and logs (without failing reconciliation) when a VM cannot start. `POST /api/vms/{id}/start` and `/stop` drive `internal/qemu`. QEMU runs with KVM (`-accel kvm`), every attached thin-LVM disk as a virtio drive (in `vm_disks.position` order), optional read-only CD-ROM boot media, and `-nic none`. VMs with attached vhost-user interfaces also get `-object memory-backend-memfd,...,share=on` plus `-numa node,memdev=mem`; without shared guest RAM VPP cannot map the virtqueues and the vhost link stays down. VNC is exposed only as a Unix socket under `-run-dir` (default `/run/rookery`). Vhost-user chardevs use `reconnect=1` so QEMU reconnects when VPP recreates its sockets after a restart. QEMU always exposes a `qemu-vdagent` virtio-serial channel (`com.redhat.spice.0`); guests with `spice-vdagent` can exchange clipboard text with the console, while guests without it ignore the port. A QEMU monitor socket (`.monitor`) is local-only and used for `SendCtrlAltDel` (`sendkey ctrl-alt-delete`) and `Reset` (`system_reset`). `QEMU.Running` checks the pid file first and falls back to scanning `/proc` for `-uuid `, so a QEMU process that survived an API restart or lost its pid file is still detected; start is then idempotent and repairs the stored status, while stop terminates the orphan by pid. QEMU start failures report the pid/name of a process holding one of the VM disks if the image lock is taken, and `reconcileVMStatuses` aligns DB status with live QEMU processes at startup. +- `POST /api/vms` creates the VM and starts it immediately; stopping a VM is always an explicit `/stop`. When the QEMU start fails, creation rolls back the VM record (and stops any partial QEMU process) and returns the start error, so callers never see a half-created VM. `vms.desired_state` (`0038_vm_desired_state.sql`) records whether the VM should be running: start sets `running`, stop sets `stopped`, and creation sets `running` after a successful start. On API startup (and after a VPP restart) `internal/api.reconcileInfrastructure` calls `autostartVMs`, which starts every VM whose desired state is `running` and whose QEMU process is gone; explicitly stopped VMs stay down. Autostart runs after the VPP resources are reconciled so vhost-user sockets exist, holds the shared run mutex, and logs (without failing reconciliation) when a VM cannot start. `POST /api/vms/{id}/start` and `/stop` drive `internal/qemu`. QEMU runs with KVM (`-accel kvm`), every attached thin-LVM disk as a virtio drive (in `vm_disks.position` order), optional read-only CD-ROM boot media, and `-nic none`. VMs with attached vhost-user interfaces also get `-object memory-backend-memfd,...,share=on` plus `-numa node,memdev=mem`; without shared guest RAM VPP cannot map the virtqueues and the vhost link stays down. VNC is exposed only as a Unix socket under `-run-dir` (default `/run/rookery`). Vhost-user chardevs use `reconnect=1` so QEMU reconnects when VPP recreates its sockets after a restart. QEMU always exposes a `qemu-vdagent` virtio-serial channel (`com.redhat.spice.0`); guests with `spice-vdagent` can exchange clipboard text with the console, while guests without it ignore the port. A QEMU monitor socket (`.monitor`) is local-only and used for `SendCtrlAltDel` (`sendkey ctrl-alt-delete`), `Reset` (`system_reset`), live `info status`/run-state reporting, and `Resume` (`cont`). Running VMs expose `run_state` in VM responses; a remote disk error pauses the VM as `paused (io-error)`, and the UI shows the paused state with a **Resume** action. `POST /api/vms/{id}/resume` resumes only a paused VM. `QEMU.Running` checks the pid file first and falls back to scanning `/proc` for `-uuid `, so a QEMU process that survived an API restart or lost its pid file is still detected; start is then idempotent and repairs the stored status, while stop terminates the orphan by pid. QEMU start failures report the pid/name of a process holding one of the VM disks if the image lock is taken, and `reconcileVMStatuses` aligns DB status with live QEMU processes at startup. - cgroup v2 resource management is in `internal/cgroup` and integrated through `internal/qemu`. Host settings and reusable class definitions (CPU cpuset, memory.max, cpu.weight, per-device io.max/io.weight, and slash-separated class paths such as `prod/web`) are stored in `cgroup_settings` (`0039_cgroup.sql`) and managed by `GET`/`PUT /api/cgroup-settings`. VMs store only `vms.cgroup_class` (`0040_cgroup_classes.sql`); an empty class selects the shared cgroup. `serve -cgroup-root` (default `/sys/fs/cgroup/rookery-cgroup`) creates a parent cgroup with no processes, a `shared` child, and class cgroups directly below the root (`/prod/web`). Classes are limited to three levels below the `rookery-cgroup` root and must have their parent class defined; VMs may only be assigned to leaf classes, while parent classes group children and distribute controllers. Class nodes inherit host/parent limits; omitted class values inherit. Every QEMU start obtains a placement from `cgroup.Manager` and uses `SysProcAttr.UseCgroupFD` (plus a `cgroup.procs` fallback) so all QEMUs run under the hierarchy. `/api/host/block-devices` lists whole devices for the I/O picker; devices are resolved to major/minor with `DeviceNumber`. Settings are validated and rolled back on kernel apply failures, and removing a class assigned to a VM is rejected. VM updates apply class changes live through `QEMU.ApplyCgroup`, which moves the running QEMU process between the shared and leaf class cgroups without restarting it. Host settings saves also call `ApplyCgroup` for every running VM (via `systemService.applyCgroupsToRunningVMs`), so enabling limits or changing host defaults takes effect immediately; failures are reported per VM after the host settings are saved. Class limits require host-wide cgroup limits to be enabled. Disabling host settings moves every process out of the Rookery hierarchy and removes the cgroups, so no QEMU process remains constrained. `GET /api/cgroup-usage` reads `memory.current`/`memory.max`, cumulative `cpu.stat`, `pids.current`/`pids.max`, `io.stat`, and `cpuset.cpus.effective` for the root, shared cgroup, and every configured class; the Settings UI polls it every 2 seconds while visible and derives CPU/I/O rates from consecutive samples. - Inventory entities support free-form labels stored in `entity_labels` (`0041_entity_labels.sql`). The `LabelService` exposes `GET /api/labels/{entity_type}`, `GET /api/labels/{entity_type}/{entity_id}`, and `PUT /api/labels/{entity_type}/{entity_id}`; setting labels replaces the full set. Supported entity types are `vm`, `disk`, `media`, `network`, `interface`, `vnf`, `acl`, `route_table`, `route`, `network_pipe`, `storage_backend`, `host_tap`, and `host_interface`. Labels are trimmed, deduplicated, sorted, limited to 64 per entity and 64 characters each, and may not contain commas or control characters. Entity list payloads do not embed labels; the UI loads them per type and shows label chips plus a reusable labels dialog. Terraform resources and data sources expose a `labels` set and apply it through the label API, including label-only updates for otherwise immutable resources. -- `GET /api/vms/{id}/metrics` returns process-level QEMU metrics from `/proc` (summed per-thread `schedstat` CPU, `VmRSS`/`VmSize`, and `/proc//io` counters). The endpoint also sums VPP vhost-interface RX/TX counters (via `vppctl show interface `) for the VM's interfaces. The UI **Monitor** dialog polls it every 2s, derives CPU/disk/network rates, and keeps 60 samples for dependency-free canvas history charts; stopped VMs return `running:false`. Do not present these as guest-OS metrics. +- `GET /api/vms/{id}/metrics` returns process-level QEMU metrics from `/proc` (summed per-thread `schedstat` CPU, `VmRSS`/`VmSize`, and `/proc//io` counters). It also queries QEMU QMP `query-blockstats` for per-drive `read_bytes`, `write_bytes`, operations, and failed/invalid operation counters; every main drive has a stable `id=rookery-disk-` so the UI Monitor dialog can map stats to disks. The endpoint also sums VPP vhost-interface RX/TX counters (via `vppctl show interface `) for the VM's interfaces. The UI **Monitor** dialog polls it every 2s, derives CPU/disk/network rates, and keeps 60 samples for dependency-free canvas history charts; stopped VMs return `running:false`. Do not present these as guest-OS metrics. - `POST /api/vms/{id}/ctrl-alt-del` and `/reset` inject those commands through the QEMU monitor. The console's Ctrl+Alt+Del button uses the API path first and falls back to VNC key events; Reset falls back to a stop/start power cycle when the monitor socket is missing (for VMs started by older servers). - `GET /ws/vms/{id}/vnc` proxies the WebSocket to the VM's VNC Unix socket. Keep the `responseRecorder` in `internal/api/server.go` implementing `http.Hijacker` (and `Flush`), or WebSocket upgrades break. The UI console page is `/console.html?vm=` and uses the vendored noVNC client. It includes a sticky-modifier virtual keyboard; key definitions use noVNC's `KeyTable` keysyms and standard `KeyboardEvent.code` scancodes. The console **Paste** dialog can type text as US keyboard events (the reliable fallback for guests without clipboard tools) or send it through noVNC `clipboardPasteFrom`, optionally reads the browser clipboard, and falls back to a manual text box when clipboard permissions are unavailable. - `serve` must start even when prerequisites (or the VNF daemon) are missing. It reports state via `GET /api/status`, blocks default-backend disk creation when the bootstrap thin pool is not ready, and blocks `POST /api/vms` until VPP and at least one unattached disk are ready; the thin pool is required only when a selected disk is LVM-backed, so a VM using only host passthrough disks can start while the thin pool is missing. Additional storage backends are validated by their driver when a disk is created. Media registration has no readiness gate. Do not turn readiness into a startup failure; QEMU/KVM availability is checked when a VM is created or started. -- Storage backends are records in `storage_backends` (`0011_storage_backends.sql`): name, title, description, driver, JSON config, and disk count. Only driver `lvm-thin` is implemented now (`config.vg`, `config.thin_pool`, `config.supports_templates`, `config.preparation_script`, `config.preparation_timeout`, `config.allow_template_delete_with_clones`); keep the driver field and API shape extensible for future drivers such as Ceph. `serve` bootstraps a `default` backend from `-vg`/`-thin-pool` when supplied and associates legacy disks with matching `vg`/`thin_pool`; without those flags it uses the first registered backend and updates readiness when a backend is added in the UI. CRUD is at `/api/storage-backends`; changing the driver, volume group, or thin pool is blocked while disks use it, while template/script settings may be updated. Deleting a backend with disks is blocked. LVM backends expose `GET /api/storage-backends/{id}/importable-disks` and `POST /api/storage-backends/{id}/import-disks`; import registers existing thin volumes (via `lvs`) transactionally, marks already-registered volumes, does not mutate LVM, and sizes are rounded up from `lvs --units g`. +- Storage backends are records in `storage_backends` (`0011_storage_backends.sql`): name, title, description, driver, JSON config, and disk count. Drivers are `lvm-thin` (`config.vg`, `config.thin_pool`, `config.supports_templates`, `config.preparation_script`, `config.preparation_timeout`, `config.allow_template_delete_with_clones`) and `kouja` (`config.kouja` with endpoint, protocol, storage class, initiator/host NQN, PEM-encoded CA/client certificate/client key content or legacy file paths, server name, and portal); keep the driver field and API shape extensible for future drivers such as Ceph. `serve` bootstraps a `default` backend from `-vg`/`-thin-pool` when supplied and associates legacy disks with matching `vg`/`thin_pool`; without those flags it uses the first registered backend and updates readiness when a backend is added in the UI. CRUD is at `/api/storage-backends`; changing the driver, volume group, or thin pool is blocked while disks use it, while template/script settings may be updated. Deleting a backend with disks is blocked. LVM backends expose `GET /api/storage-backends/{id}/importable-disks` and `POST /api/storage-backends/{id}/import-disks`; import registers existing thin volumes (via `lvs`) transactionally, marks already-registered volumes, does not mutate LVM, and sizes are rounded up from `lvs --units g`. - Bridge-domain pipes (`network_pipes`, migration `0033_network_pipes.sql`) connect two Rookery Networks with a VPP pipe pair. `GET`/`POST /api/network-pipes` and `DELETE /api/network-pipes/{id}` manage them; a pair of networks can have only one pipe, and a network with a pipe cannot be deleted. `internal/host.CreatePipe`/`EnsurePipe` create or find the pipe by VPP instance, attach both ends to the selected bridge domains with a 1500-byte MTU, and roll back a newly created pipe on failure. The API recreates recorded pipes after a VPP restart and refreshes their runtime indices. The generic `/api/vpp/interfaces/{name}/addresses` endpoint recognizes the pipe endpoint names (`pipe.0`/`.1`), so users can assign IPv4/IPv6 addresses to either end; those addresses are persisted in `vpp_interface_addresses`, reapplied after restart, and cleaned up when the pipe record is deleted. The UI exposes them as the **BD pipes** subtab with per-endpoint address actions. -- Disks are first-class LVM-backed resources: `POST /api/disks` selects a `storage_backend_id` (or the default backend), runs `lvcreate` for a blank thin volume, or invokes the backend preparation script to clone a template when `template_disk_id` is supplied. Template and clone disks are recorded in migration `0013_disk_templates.sql`; templates are backend-scoped, cannot attach to VMs, and cloning requires `supports_templates` plus a configured script. The script runs as the API user, receives `ROOKERY_DISK_JSON` (operation, request id, disk, template, backend), has a configurable timeout, logs output, and must create the clone LV/device. Template disks may also configure `provisioning_script`/`provisioning_timeout` (`0037_disk_template_provisioning.sql`); after the preparation script creates the clone, Rookery runs it as the API user with `ROOKERY_PROVISION_JSON` (`operation`, request id, disk, template, backend, and arbitrary caller `properties` from the clone's `provisioning` JSON), and rolls back the clone on failure. `scripts/template-provision.sh` is the reference example; it logs each stage and always unmounts the clone. `PUT /api/disks/{id}` marks/unmarks templates; deleting a template with clones requires `allow_template_delete_with_clones`. `DELETE /api/disks/{id}` still removes empty and clone LVs, with script-based removal for script-created clones. VMs can have multiple disks through the `vm_disks` join table (`0012_vm_disks.sql`); `vms.disk_id` remains the legacy primary/first-disk column and is kept in sync. VM creation requires at least one `disk_ids` entry (`disk_id` is accepted as a single-disk alias); `PUT` with `disk_ids` replaces the set only while stopped, and omitting it preserves the set. A disk can attach to only one VM; deleting a VM releases all of its disks. Keep the host operations and store records consistent (rollback the LV if the DB insert fails; delete the LV before the record). Migration `0048_disk_host_devices.sql` adds `disks.source`, `disks.device`, and `disks.size_bytes`; `source` is `lvm-thin` for managed volumes or `host-device` for passthrough. `GET /api/host/block-devices/attachable` lists whole disks, partitions, LVs, and RAID arrays (with mount/swap/holder/registration state), and `POST /api/disks` registers one with `host_device` instead of `storage_backend_id`/`size_gb`. Passthrough disks cannot be templates or clones; deleting one removes only the inventory record; the device path is stored verbatim and devices are matched by `os.SameFile` for duplicate detection, with whole-disk/partition overlap rejected via sysfs ancestry. `internal/host.ValidateAttachableBlockDevice` rejects non-block paths, read-only/mounted/swap devices, devices that back another dm/md object, and configured thin pools, and `internal/qemu.Start` revalidates host devices so a device mounted after registration is not handed to a guest. Before starting an LVM-backed VM, `internal/qemu.Start` calls `host.LVM.ActivateThinVolume` (`lvchange -ay -K` for the pool and volume) when the device node is missing, because thin volumes can remain in LVM metadata while inactive after a reboot. VM creation requires the thin pool only when at least one selected disk is LVM-backed; VPP remains required. +- Kouja backends are remote iSCSI storage: `POST /api/disks` with a kouja backend creates and exports a volume through the pinned kouja client (the volume ID and request ID are persisted in `backend_metadata`), returns a secret-free `locator`, and leaves `device` empty. QEMU attaches the volume with its own iSCSI block driver and a transient 0600 CHAP secret under ``; the host never opens an `iscsiadm` session and the CHAP secret never appears in QEMU args or SQLite. `DELETE /api/disks/{id}` on a kouja disk returns `202 Accepted`, marks the row `deleting`, and the background reclaimer unexports/deletes it with bounded retries; `delete_failed` rows can be retried with the same call. VM starts use `vmstart.Planner` to fetch a fresh export/credential and `qemu.StartWithPlan`; API autostart uses the same planner. Kouja stores session observations at its reconcile interval, so a stale `SESSION_ACTIVE` must not block a start after QEMU logs out; only a fresh non-zero `Observation.ActiveSessions` is authoritative. When a fresh active session is reported, the driver waits briefly and re-prepares the export, then the gateway returns `Unavailable` so the UI retries after the previous QEMU/iSCSI session drains. The UI backend form supports kouja, the disk table shows source/protocol/locator/deletion state, and VM creation requires the thin pool only for LVM-backed disks. Deep data-path testing is not implemented yet (`TestStorageBackendDataPath` returns `Unimplemented`). +- Disks are first-class LVM-backed resources: `POST /api/disks` selects a `storage_backend_id` (or the default backend), runs `lvcreate` for a blank thin volume, or invokes the backend preparation script to clone a template when `template_disk_id` is supplied. Template and clone disks are recorded in migration `0013_disk_templates.sql`; templates are backend-scoped, cannot attach to VMs, and cloning requires `supports_templates` plus a configured script. The script runs as the API user, receives `ROOKERY_DISK_JSON` (operation, request id, disk, template, backend), has a configurable timeout, logs output, and must create the clone LV/device. Template disks may also configure `provisioning_script`/`provisioning_timeout` (`0037_disk_template_provisioning.sql`); after the preparation script creates the clone, Rookery runs it as the API user with `ROOKERY_PROVISION_JSON` (`operation`, request id, disk, template, backend, and arbitrary caller `properties` from the clone's `provisioning` JSON), and rolls back the clone on failure. `scripts/template-provision.sh` is the reference example; it logs each stage and always unmounts the clone. `PUT /api/disks/{id}` marks/unmarks templates; deleting a template with clones requires `allow_template_delete_with_clones`. `DELETE /api/disks/{id}` still removes empty and clone LVs, with script-based removal for script-created clones. VMs can have multiple disks through the `vm_disks` join table (`0012_vm_disks.sql`); `vms.disk_id` remains the legacy primary/first-disk column and is kept in sync. VM creation requires at least one `disk_ids` entry (`disk_id` is accepted as a single-disk alias); `PUT` with `disk_ids` replaces the set only while stopped, and omitting it preserves the set. A disk can attach to only one VM; deleting a VM releases all of its disks. Keep the host operations and store records consistent (rollback the LV if the DB insert fails; delete the LV before the record). Migration `0048_disk_host_devices.sql` adds `disks.source`, `disks.device`, and `disks.size_bytes`; `source` is `lvm-thin` for managed volumes or `host-device` for passthrough. `GET /api/host/block-devices/attachable` lists whole disks, partitions, LVs, and RAID arrays (with mount/swap/holder/registration state), and `POST /api/disks` registers one with `host_device` instead of `storage_backend_id`/`size_gb`. Passthrough disks cannot be templates or clones; deleting one removes only the inventory record; the device path is stored verbatim and devices are matched by `os.SameFile` for duplicate detection, with whole-disk/partition overlap rejected via sysfs ancestry. `internal/host.ValidateAttachableBlockDevice` rejects non-block paths, read-only/mounted/swap devices, devices that back another dm/md object, and configured thin pools, and `internal/qemu.Start` revalidates host devices so a device mounted after registration is not handed to a guest. Disks also carry optional `read_bps` and `write_bps` QEMU block-throttling limits in bytes per second (zero means unlimited); the UI exposes them on disk creation and through the **Limits** row action, and `internal/qemu.buildDiskArgs` emits `throttling.bps-read`/`throttling.bps-write` for both local and remote drives. Changes apply on the next VM start. Before starting an LVM-backed VM, `internal/qemu.Start` calls `host.LVM.ActivateThinVolume` (`lvchange -ay -K` for the pool and volume) when the device node is missing, because thin volumes can remain in LVM metadata while inactive after a reboot. VM creation requires the thin pool only when at least one selected disk is LVM-backed; VPP remains required. - Installation media are read-only path-backed records: `POST /api/media` stats an existing absolute regular file, records its server-derived size, and never copies or modifies it. `DELETE /api/media/{id}` removes only the inventory entry and is blocked while a VM uses the media as boot media. - LightVM inventory starts with kernels in `lightvm_kernels` (`0042_lightvm.sql`), initramfs in `lightvm_initramfs` (`0043_lightvm_initramfs.sql`), instances in `lightvms` (`0044_lightvms.sql`, with kernel command line `append` added in `0045_lightvm_append.sql` and lifecycle state in `0046_lightvm_lifecycle.sql`), and console ports in `lightvm_consoles` (`0047_lightvm_consoles.sql`). Uploads use the native `POST /api/lightvm/uploads` multipart endpoint (local listener only), which streams files under `-lightvm-dir` (`ROOKERY_LIGHTVM_DIR`, default `/var/lib/rookery/lightvm`) with a UUID-prefixed name and a 1 GiB limit; inventory CRUD lives at `/api/lightvm/kernels`, `/api/lightvm/initramfs`, and `/api/lightvm/instances`, and labels use entity types `lightvm_kernel`, `lightvm_initramfs`, and `lightvm`. LightVMs use UUIDs and include name, description, vCPUs, memory, console flag, cgroup class, kernel/initramfs IDs, a container image, ordered env name/value pairs, an executable path, argument list, an ordered interface set, an ordered console list, and a kernel command line (`append`, default `console=hvc0 rdinit=/init`). Every LightVM has at least one console (up to 32); the first is exposed as `org.example.console`, additional ones as `org.example.console.`. `internal/lightvm.Runtime` pulls the container image with buildah if missing, creates an ephemeral writable `rookery-lightvm-` container, mounts its rootfs, writes the definition's env pairs to `/.env` as one `NAME=value` line each and an executable `/.entrypoint` script containing the executable path and arguments, starts virtiofsd on `/.virtiofs.sock` (binary resolved next to the rookery executable or by `-virtiofsd-bin`), and then calls `internal/qemu.StartLightVMWithVirtioFS`. QEMU boots with `-kernel`/`-initrd`/`-append` and no block device or `-machine` override, a `vhost-user-fs-pci` device tagged `rootfs`, one virtio console socket per configured console under the run directory, the persisted cgroup class, and vhost-user interfaces; it uses `-display none` because `-nographic` cannot be combined with `-daemonize`, and it never adds a VNC or graphical device. Stop stops QEMU, virtiofsd, and removes the buildah container, so guest writes are discarded. Start/stop is exposed at `/api/lightvm/instances/{id}/start` and `/stop`; the response exposes `console_socket` for the first console and a `consoles` list with each port's `id`, `name`, `port_name`, and `socket` while running, and status/desired_state reconcile on API startup and after VPP restarts. The native WebSocket `/ws/lightvms/{id}/console?console=` proxies the selected virtio console socket; without `console`, the first console is used. Like the VM VNC proxy it is local-listener-only, checks `console_enabled` server-side, and closes an open session when access is disabled or the LightVM stops. The standalone UI page is `/lightvm-console.html?vm=&console=` (xterm.js with bundled JetBrains Mono, persisted color-theme and font-size selection, common Ctrl/Esc/Tab buttons, a Ctrl latch for arbitrary Ctrl+key chords, and a console selector when the LightVM has multiple ports) and the LightVMs table opens it with a **Console** action; this page is the only route whose CSP allows `style-src 'unsafe-inline'` so xterm can inject its cursor/DOM styles. `size_bytes` and `managed` are server-derived, and deleting an uploaded record removes its managed file while host-path records leave the file alone. The environment and executable fields are not passed to QEMU; the initramfs defines the workload contract and mounts the `rootfs` virtio-fs tag. `/api/status` includes a `lightvm` component reporting buildah/virtiofsd availability; the LightVM tab shows a runtime-unavailable banner and disables Start when either binary is missing. - VMs have an optional `boot_media_id`. It may be set on create and changed or cleared on `PUT`; unlike disks, one media entry can be used by multiple VMs. diff --git a/cmd/terraform-provider-rookery/internal/provider/data_source_disk.go b/cmd/terraform-provider-rookery/internal/provider/data_source_disk.go index 711a19e..cb7b61c 100644 --- a/cmd/terraform-provider-rookery/internal/provider/data_source_disk.go +++ b/cmd/terraform-provider-rookery/internal/provider/data_source_disk.go @@ -38,6 +38,10 @@ func (d *diskDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r "size_gb": dschema.Int64Attribute{Computed: true}, "size_bytes": dschema.Int64Attribute{Computed: true}, "source": dschema.StringAttribute{Computed: true}, + "protocol": dschema.StringAttribute{Computed: true}, + "locator": dschema.StringAttribute{Computed: true}, + "deletion_state": dschema.StringAttribute{Computed: true}, + "deletion_error": dschema.StringAttribute{Computed: true}, "host_device": dschema.StringAttribute{Computed: true}, "storage_backend_id": dschema.Int64Attribute{Computed: true}, "storage_backend_name": dschema.StringAttribute{Computed: true}, diff --git a/cmd/terraform-provider-rookery/internal/provider/kouja_state_test.go b/cmd/terraform-provider-rookery/internal/provider/kouja_state_test.go new file mode 100644 index 0000000..6ef7bff --- /dev/null +++ b/cmd/terraform-provider-rookery/internal/provider/kouja_state_test.go @@ -0,0 +1,120 @@ +package provider + +import ( + "context" + "errors" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" +) + +type fakeDiskClient struct { + rookeryv1.DiskServiceClient + deleteResponse *rookeryv1.DeleteDiskResponse + deleteErr error + getResponse *rookeryv1.Disk + getErr error +} + +func (f *fakeDiskClient) DeleteDisk(context.Context, *rookeryv1.DeleteDiskRequest, ...grpc.CallOption) (*rookeryv1.DeleteDiskResponse, error) { + return f.deleteResponse, f.deleteErr +} + +func (f *fakeDiskClient) GetDisk(context.Context, *rookeryv1.GetDiskRequest, ...grpc.CallOption) (*rookeryv1.Disk, error) { + return f.getResponse, f.getErr +} + +func TestDeleteDiskAsyncBranches(t *testing.T) { + ctx := context.Background() + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{}}, 1, time.Second, time.Millisecond); err != nil { + t.Fatalf("completed delete = %v", err) + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteErr: status.Error(codes.NotFound, "gone")}, 1, time.Second, time.Millisecond); err != nil { + t.Fatalf("not-found delete = %v", err) + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteErr: errors.New("boom")}, 1, time.Second, time.Millisecond); err == nil { + t.Fatal("delete error was ignored") + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{Accepted: true}, + getErr: status.Error(codes.NotFound, "gone")}, 1, time.Second, time.Millisecond); err != nil { + t.Fatalf("accepted not-found = %v", err) + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{Accepted: true}, + getErr: errors.New("read failed")}, 1, time.Second, time.Millisecond); err == nil { + t.Fatal("accepted read error was ignored") + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{Accepted: true}, + getResponse: &rookeryv1.Disk{DeletionState: "delete_failed", DeletionError: "failed"}}, 1, time.Second, time.Millisecond); err == nil { + t.Fatal("delete_failed was ignored") + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{Accepted: true}, + getResponse: &rookeryv1.Disk{}}, 1, time.Second, time.Millisecond); err != nil { + t.Fatalf("completed polling = %v", err) + } + if err := deleteDiskAsync(ctx, &fakeDiskClient{deleteResponse: &rookeryv1.DeleteDiskResponse{Accepted: true}, + getResponse: &rookeryv1.Disk{DeletionState: "deleting"}}, 1, time.Millisecond, time.Millisecond); err == nil { + t.Fatal("poll timeout was ignored") + } +} + +func TestSetDiskStateRemoteFields(t *testing.T) { + var state diskModel + setDiskState(&state, &rookeryv1.Disk{ + Id: 7, Name: "remote", SizeGb: 5, Source: "kouja", Protocol: "iscsi", + Locator: "10.0.0.1:3260 iqn.2026-11.io.example:disk", DeletionState: "deleting", + DeletionError: "slow", Device: "/dev/ignored", + }) + if state.Protocol.ValueString() != "iscsi" || state.Locator.ValueString() == "" || + state.DeletionState.ValueString() != "deleting" || state.DeletionError.ValueString() != "slow" { + t.Fatalf("state = %+v", state) + } + if !state.HostDevice.IsNull() { + t.Fatal("remote disk was treated as a host device") + } + setDiskState(&state, &rookeryv1.Disk{Id: 8, Source: "host-device", Device: "/dev/sdb"}) + if state.HostDevice.ValueString() != "/dev/sdb" { + t.Fatalf("host device = %q", state.HostDevice.ValueString()) + } +} + +func TestStorageBackendKoujaRoundTrip(t *testing.T) { + ctx := context.Background() + var state storageBackendModel + backend := &rookeryv1.StorageBackend{ + Id: 3, Name: "kouja", Driver: "kouja", DiskCount: 1, + Config: &rookeryv1.StorageBackendConfig{DriverConfig: &rookeryv1.StorageBackendConfig_Kouja{ + Kouja: &rookeryv1.KoujaConfig{ + Endpoint: "10.0.0.1:9443", Protocol: "iscsi", StorageClass: "nvme", + Initiator: "iqn.2026-11.io.example:rookery", HostNqn: "nqn.2026-11.io.example:rookery", + CaFile: "/etc/ca.crt", ClientCertFile: "/etc/client.crt", ClientKeyFile: "/etc/client.key", + ServerName: "kouja", Portal: "10.0.0.1:3260", + }, + }}, + } + if diags := setStorageBackendState(ctx, &state, backend); diags.HasError() { + t.Fatalf("setStorageBackendState: %v", diags) + } + config, diags := storageBackendConfigFromObject(ctx, state.Config) + if diags.HasError() { + t.Fatalf("storageBackendConfigFromObject: %v", diags) + } + if config.GetKouja().GetEndpoint() != "10.0.0.1:9443" || config.GetKouja().GetStorageClass() != "nvme" || + config.GetKouja().GetClientKeyFile() != "/etc/client.key" { + t.Fatalf("config = %+v", config) + } + var lvmState storageBackendModel + lvm := &rookeryv1.StorageBackend{Id: 4, Name: "lvm", Driver: "lvm-thin", + Config: &rookeryv1.StorageBackendConfig{Vg: "vg0", ThinPool: "pool", SupportsTemplates: true}} + if diags := setStorageBackendState(ctx, &lvmState, lvm); diags.HasError() { + t.Fatalf("setStorageBackendState(lvm): %v", diags) + } + lvmConfig, diags := storageBackendConfigFromObject(ctx, lvmState.Config) + if diags.HasError() || lvmConfig.GetVg() != "vg0" || lvmConfig.GetKouja() != nil { + t.Fatalf("lvm config = %+v, %v", lvmConfig, diags) + } +} diff --git a/cmd/terraform-provider-rookery/internal/provider/resource_disk.go b/cmd/terraform-provider-rookery/internal/provider/resource_disk.go index 733e1ca..3de49aa 100644 --- a/cmd/terraform-provider-rookery/internal/provider/resource_disk.go +++ b/cmd/terraform-provider-rookery/internal/provider/resource_disk.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -41,6 +42,10 @@ type diskModel struct { ThinPool types.String `tfsdk:"thin_pool"` Device types.String `tfsdk:"device"` Source types.String `tfsdk:"source"` + Protocol types.String `tfsdk:"protocol"` + Locator types.String `tfsdk:"locator"` + DeletionState types.String `tfsdk:"deletion_state"` + DeletionError types.String `tfsdk:"deletion_error"` HostDevice types.String `tfsdk:"host_device"` AttachedVMID types.String `tfsdk:"attached_vm_id"` AttachedVMName types.String `tfsdk:"attached_vm_name"` @@ -59,7 +64,7 @@ func (r *diskResource) Metadata(_ context.Context, req resource.MetadataRequest, func (r *diskResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "A thin-LVM disk or template clone in Rookery.", + Description: "A provisioned, remote, or passthrough disk in Rookery.", Attributes: map[string]schema.Attribute{ "labels": labelsResourceAttribute(), "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, @@ -85,8 +90,12 @@ func (r *diskResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, "source": schema.StringAttribute{ Computed: true, - Description: "Disk source: lvm-thin or host-device.", + Description: "Disk source (lvm-thin, host-device, kouja, ...).", }, + "protocol": schema.StringAttribute{Computed: true, Description: "Attachment protocol."}, + "locator": schema.StringAttribute{Computed: true, Description: "Display locator for remote disks."}, + "deletion_state": schema.StringAttribute{Computed: true, Description: "Asynchronous deletion state."}, + "deletion_error": schema.StringAttribute{Computed: true, Description: "Last asynchronous deletion error."}, "storage_backend_id": schema.Int64Attribute{ Optional: true, Computed: true, @@ -249,13 +258,48 @@ func (r *diskResource) Delete(ctx context.Context, req resource.DeleteRequest, r resp.Diagnostics.AddError("Delete disk", err.Error()) return } - callCtx, cancel := context.WithTimeout(ctx, client.Timeout()) + callCtx, cancel := context.WithTimeout(ctx, 4*time.Hour) defer cancel() - if _, err := client.Disks.DeleteDisk(callCtx, &rookeryv1.DeleteDiskRequest{Id: state.ID.ValueInt64()}); err != nil && !isNotFound(err) { + if err := deleteDiskAsync(callCtx, client.Disks, state.ID.ValueInt64(), 4*time.Hour, 2*time.Second); err != nil { resp.Diagnostics.AddError("Delete disk", err.Error()) } } +func deleteDiskAsync(ctx context.Context, client rookeryv1.DiskServiceClient, id int64, timeout, poll time.Duration) error { + deleted, err := client.DeleteDisk(ctx, &rookeryv1.DeleteDiskRequest{Id: id}) + if err != nil { + if isNotFound(err) { + return nil + } + return err + } + if deleted == nil || !deleted.GetAccepted() { + return nil + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(poll): + } + disk, readErr := client.GetDisk(ctx, &rookeryv1.GetDiskRequest{Id: id}) + if isNotFound(readErr) { + return nil + } + if readErr != nil { + return readErr + } + if disk.GetDeletionState() == "delete_failed" { + return fmt.Errorf("%s", disk.GetDeletionError()) + } + if disk.GetDeletionState() == "" { + return nil + } + } + return fmt.Errorf("timed out waiting for asynchronous disk deletion") +} + func (r *diskResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { importInt64ID(ctx, req, resp) } @@ -271,6 +315,10 @@ func setDiskState(state *diskModel, disk *rookeryv1.Disk) { state.ThinPool = types.StringValue(disk.GetThinPool()) state.Device = types.StringValue(disk.GetDevice()) state.Source = types.StringValue(disk.GetSource()) + state.Protocol = optionalStringValue(disk.GetProtocol()) + state.Locator = optionalStringValue(disk.GetLocator()) + state.DeletionState = optionalStringValue(disk.GetDeletionState()) + state.DeletionError = optionalStringValue(disk.GetDeletionError()) if disk.GetSource() == "host-device" { state.HostDevice = types.StringValue(disk.GetDevice()) } else { diff --git a/cmd/terraform-provider-rookery/internal/provider/resource_storage_backend.go b/cmd/terraform-provider-rookery/internal/provider/resource_storage_backend.go index c86eede..e4a52c9 100644 --- a/cmd/terraform-provider-rookery/internal/provider/resource_storage_backend.go +++ b/cmd/terraform-provider-rookery/internal/provider/resource_storage_backend.go @@ -48,6 +48,33 @@ type storageBackendConfigModel struct { PreparationScript types.String `tfsdk:"preparation_script"` PreparationTimeout types.String `tfsdk:"preparation_timeout"` AllowTemplateDeleteWithClones types.Bool `tfsdk:"allow_template_delete_with_clones"` + Kouja types.Object `tfsdk:"kouja"` +} + +type koujaConfigModel struct { + Endpoint types.String `tfsdk:"endpoint"` + Protocol types.String `tfsdk:"protocol"` + StorageClass types.String `tfsdk:"storage_class"` + Initiator types.String `tfsdk:"initiator"` + HostNQN types.String `tfsdk:"host_nqn"` + CAFile types.String `tfsdk:"ca_file"` + ClientCertFile types.String `tfsdk:"client_cert_file"` + ClientKeyFile types.String `tfsdk:"client_key_file"` + ServerName types.String `tfsdk:"server_name"` + Portal types.String `tfsdk:"portal"` +} + +var koujaConfigAttrTypes = map[string]attr.Type{ + "endpoint": types.StringType, + "protocol": types.StringType, + "storage_class": types.StringType, + "initiator": types.StringType, + "host_nqn": types.StringType, + "ca_file": types.StringType, + "client_cert_file": types.StringType, + "client_key_file": types.StringType, + "server_name": types.StringType, + "portal": types.StringType, } var storageBackendConfigAttrTypes = map[string]attr.Type{ @@ -57,6 +84,7 @@ var storageBackendConfigAttrTypes = map[string]attr.Type{ "preparation_script": types.StringType, "preparation_timeout": types.StringType, "allow_template_delete_with_clones": types.BoolType, + "kouja": types.ObjectType{AttrTypes: koujaConfigAttrTypes}, } func (r *storageBackendResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -65,7 +93,7 @@ func (r *storageBackendResource) Metadata(_ context.Context, req resource.Metada func (r *storageBackendResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "A Rookery storage backend. Only the lvm-thin driver is currently supported.", + Description: "A Rookery storage backend. Supports lvm-thin and kouja drivers.", Attributes: map[string]schema.Attribute{ "labels": labelsResourceAttribute(), "id": schema.Int64Attribute{ @@ -78,15 +106,15 @@ func (r *storageBackendResource) Schema(_ context.Context, _ resource.SchemaRequ "driver": schema.StringAttribute{ Optional: true, Computed: true, - Description: "Storage driver. Defaults to lvm-thin.", + Description: "Storage driver: lvm-thin or kouja. Defaults to lvm-thin.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "config": schema.SingleNestedAttribute{ Required: true, - Description: "LVM thin-pool and template configuration.", + Description: "Driver-specific configuration. Use vg/thin_pool for LVM or kouja for a kouja backend.", Attributes: map[string]schema.Attribute{ - "vg": schema.StringAttribute{Required: true, Description: "Volume group."}, - "thin_pool": schema.StringAttribute{Required: true, Description: "Thin pool name."}, + "vg": schema.StringAttribute{Optional: true, Computed: true, Description: "Volume group for lvm-thin."}, + "thin_pool": schema.StringAttribute{Optional: true, Computed: true, Description: "Thin pool for lvm-thin."}, "supports_templates": schema.BoolAttribute{Optional: true, Computed: true, Description: "Allow template disks on this backend."}, "preparation_script": schema.StringAttribute{Optional: true, Computed: true, Description: "Script used to clone template disks."}, "preparation_timeout": schema.StringAttribute{Optional: true, Computed: true, Description: "Preparation script timeout."}, @@ -95,6 +123,22 @@ func (r *storageBackendResource) Schema(_ context.Context, _ resource.SchemaRequ Computed: true, Description: "Allow deleting a template while clones exist.", }, + "kouja": schema.SingleNestedAttribute{ + Optional: true, + Description: "Kouja endpoint, storage class, and mTLS client configuration.", + Attributes: map[string]schema.Attribute{ + "endpoint": schema.StringAttribute{Optional: true, Computed: true}, + "protocol": schema.StringAttribute{Optional: true, Computed: true}, + "storage_class": schema.StringAttribute{Optional: true, Computed: true}, + "initiator": schema.StringAttribute{Optional: true, Computed: true}, + "host_nqn": schema.StringAttribute{Optional: true, Computed: true}, + "ca_file": schema.StringAttribute{Optional: true, Computed: true}, + "client_cert_file": schema.StringAttribute{Optional: true, Computed: true}, + "client_key_file": schema.StringAttribute{Optional: true, Computed: true}, + "server_name": schema.StringAttribute{Optional: true, Computed: true}, + "portal": schema.StringAttribute{Optional: true, Computed: true}, + }, + }, }, }, "disk_count": schema.Int64Attribute{Computed: true, Description: "Number of disks on this backend."}, @@ -244,6 +288,19 @@ func setStorageBackendState(ctx context.Context, state *storageBackendModel, bac state.Driver = types.StringValue(backend.GetDriver()) state.DiskCount = types.Int64Value(int64(backend.GetDiskCount())) cfg := backend.GetConfig() + kouja := types.ObjectNull(koujaConfigAttrTypes) + if cfg.GetKouja() != nil { + value := koujaConfigModel{ + Endpoint: types.StringValue(cfg.GetKouja().GetEndpoint()), Protocol: optionalStringValue(cfg.GetKouja().GetProtocol()), + StorageClass: types.StringValue(cfg.GetKouja().GetStorageClass()), Initiator: optionalStringValue(cfg.GetKouja().GetInitiator()), + HostNQN: optionalStringValue(cfg.GetKouja().GetHostNqn()), CAFile: types.StringValue(cfg.GetKouja().GetCaFile()), + ClientCertFile: types.StringValue(cfg.GetKouja().GetClientCertFile()), ClientKeyFile: types.StringValue(cfg.GetKouja().GetClientKeyFile()), + ServerName: optionalStringValue(cfg.GetKouja().GetServerName()), Portal: optionalStringValue(cfg.GetKouja().GetPortal()), + } + object, objectDiags := types.ObjectValueFrom(ctx, koujaConfigAttrTypes, value) + diags.Append(objectDiags...) + kouja = object + } config := storageBackendConfigModel{ VG: types.StringValue(cfg.GetVg()), ThinPool: types.StringValue(cfg.GetThinPool()), @@ -251,6 +308,7 @@ func setStorageBackendState(ctx context.Context, state *storageBackendModel, bac PreparationScript: optionalStringValue(cfg.GetPreparationScript()), PreparationTimeout: optionalStringValue(cfg.GetPreparationTimeout()), AllowTemplateDeleteWithClones: types.BoolValue(cfg.GetAllowTemplateDeleteWithClones()), + Kouja: kouja, } object, objectDiags := types.ObjectValueFrom(ctx, storageBackendConfigAttrTypes, config) diags.Append(objectDiags...) @@ -264,12 +322,27 @@ func storageBackendConfigFromObject(ctx context.Context, object types.Object) (* if diags.HasError() { return nil, diags } - return &rookeryv1.StorageBackendConfig{ + result := &rookeryv1.StorageBackendConfig{ Vg: config.VG.ValueString(), ThinPool: config.ThinPool.ValueString(), SupportsTemplates: config.SupportsTemplates.ValueBool(), PreparationScript: config.PreparationScript.ValueString(), PreparationTimeout: config.PreparationTimeout.ValueString(), AllowTemplateDeleteWithClones: config.AllowTemplateDeleteWithClones.ValueBool(), - }, diags + } + if !config.Kouja.IsNull() && !config.Kouja.IsUnknown() { + var kouja koujaConfigModel + diags.Append(config.Kouja.As(ctx, &kouja, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + result.DriverConfig = &rookeryv1.StorageBackendConfig_Kouja{Kouja: &rookeryv1.KoujaConfig{ + Endpoint: kouja.Endpoint.ValueString(), Protocol: kouja.Protocol.ValueString(), + StorageClass: kouja.StorageClass.ValueString(), Initiator: kouja.Initiator.ValueString(), + HostNqn: kouja.HostNQN.ValueString(), CaFile: kouja.CAFile.ValueString(), + ClientCertFile: kouja.ClientCertFile.ValueString(), ClientKeyFile: kouja.ClientKeyFile.ValueString(), + ServerName: kouja.ServerName.ValueString(), Portal: kouja.Portal.ValueString(), + }} + } + return result, diags } diff --git a/design/gen/rookery/v1/disk.pb.go b/design/gen/rookery/v1/disk.pb.go index 305f331..09a73ad 100644 --- a/design/gen/rookery/v1/disk.pb.go +++ b/design/gen/rookery/v1/disk.pb.go @@ -42,7 +42,11 @@ type VMDisk struct { // locator is a display-only description of a remote attachment. Locator string `protobuf:"bytes,11,opt,name=locator,proto3" json:"locator,omitempty"` // protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments. - Protocol string `protobuf:"bytes,12,opt,name=protocol,proto3" json:"protocol,omitempty"` + Protocol string `protobuf:"bytes,12,opt,name=protocol,proto3" json:"protocol,omitempty"` + // read_bps and write_bps are QEMU block throttling limits in bytes per + // second. Zero means unlimited. + ReadBps int64 `protobuf:"varint,13,opt,name=read_bps,json=readBps,proto3" json:"read_bps,omitempty"` + WriteBps int64 `protobuf:"varint,14,opt,name=write_bps,json=writeBps,proto3" json:"write_bps,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -161,6 +165,20 @@ func (x *VMDisk) GetProtocol() string { return "" } +func (x *VMDisk) GetReadBps() int64 { + if x != nil { + return x.ReadBps + } + return 0 +} + +func (x *VMDisk) GetWriteBps() int64 { + if x != nil { + return x.WriteBps + } + return 0 +} + type Disk struct { state protoimpl.MessageState `protogen:"open.v1"` Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -191,7 +209,11 @@ type Disk struct { // deletion_error is the last asynchronous deletion error. DeletionError string `protobuf:"bytes,22,opt,name=deletion_error,json=deletionError,proto3" json:"deletion_error,omitempty"` // protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments. - Protocol string `protobuf:"bytes,23,opt,name=protocol,proto3" json:"protocol,omitempty"` + Protocol string `protobuf:"bytes,23,opt,name=protocol,proto3" json:"protocol,omitempty"` + // read_bps and write_bps are QEMU block throttling limits in bytes per + // second. Zero means unlimited. + ReadBps int64 `protobuf:"varint,24,opt,name=read_bps,json=readBps,proto3" json:"read_bps,omitempty"` + WriteBps int64 `protobuf:"varint,25,opt,name=write_bps,json=writeBps,proto3" json:"write_bps,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -387,6 +409,20 @@ func (x *Disk) GetProtocol() string { return "" } +func (x *Disk) GetReadBps() int64 { + if x != nil { + return x.ReadBps + } + return 0 +} + +func (x *Disk) GetWriteBps() int64 { + if x != nil { + return x.WriteBps + } + return 0 +} + type ImportableDisk struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -618,6 +654,8 @@ type CreateDiskRequest struct { // storage_backend_id, size_gb, is_template, template_disk_id, and // provisioning settings must be omitted; the size is read from the device. HostDevice string `protobuf:"bytes,9,opt,name=host_device,json=hostDevice,proto3" json:"host_device,omitempty"` + ReadBps *int64 `protobuf:"varint,10,opt,name=read_bps,json=readBps,proto3,oneof" json:"read_bps,omitempty"` + WriteBps *int64 `protobuf:"varint,11,opt,name=write_bps,json=writeBps,proto3,oneof" json:"write_bps,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -715,6 +753,20 @@ func (x *CreateDiskRequest) GetHostDevice() string { return "" } +func (x *CreateDiskRequest) GetReadBps() int64 { + if x != nil && x.ReadBps != nil { + return *x.ReadBps + } + return 0 +} + +func (x *CreateDiskRequest) GetWriteBps() int64 { + if x != nil && x.WriteBps != nil { + return *x.WriteBps + } + return 0 +} + type DeleteDiskResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` @@ -773,6 +825,8 @@ type UpdateDiskRequest struct { IsTemplate *bool `protobuf:"varint,2,opt,name=is_template,json=isTemplate,proto3,oneof" json:"is_template,omitempty"` ProvisioningScript *string `protobuf:"bytes,3,opt,name=provisioning_script,json=provisioningScript,proto3,oneof" json:"provisioning_script,omitempty"` ProvisioningTimeout *string `protobuf:"bytes,4,opt,name=provisioning_timeout,json=provisioningTimeout,proto3,oneof" json:"provisioning_timeout,omitempty"` + ReadBps *int64 `protobuf:"varint,5,opt,name=read_bps,json=readBps,proto3,oneof" json:"read_bps,omitempty"` + WriteBps *int64 `protobuf:"varint,6,opt,name=write_bps,json=writeBps,proto3,oneof" json:"write_bps,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -835,6 +889,20 @@ func (x *UpdateDiskRequest) GetProvisioningTimeout() string { return "" } +func (x *UpdateDiskRequest) GetReadBps() int64 { + if x != nil && x.ReadBps != nil { + return *x.ReadBps + } + return 0 +} + +func (x *UpdateDiskRequest) GetWriteBps() int64 { + if x != nil && x.WriteBps != nil { + return *x.WriteBps + } + return 0 +} + type DeleteDiskRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -884,7 +952,7 @@ var File_rookery_v1_disk_proto protoreflect.FileDescriptor const file_rookery_v1_disk_proto_rawDesc = "" + "\n" + "\x15rookery/v1/disk.proto\x12\n" + - "rookery.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\"\xf3\x02\n" + + "rookery.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17validate/validate.proto\"\xab\x03\n" + "\x06VMDisk\x12\x0e\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x17\n" + @@ -899,8 +967,10 @@ const file_rookery_v1_disk_proto_rawDesc = "" + "size_bytes\x18\n" + " \x01(\x04R\tsizeBytes\x12\x18\n" + "\alocator\x18\v \x01(\tR\alocator\x12\x1a\n" + - "\bprotocol\x18\f \x01(\tR\bprotocolB\x15\n" + - "\x13_storage_backend_id\"\x94\a\n" + + "\bprotocol\x18\f \x01(\tR\bprotocol\x12\x19\n" + + "\bread_bps\x18\r \x01(\x03R\areadBps\x12\x1b\n" + + "\twrite_bps\x18\x0e \x01(\x03R\bwriteBpsB\x15\n" + + "\x13_storage_backend_id\"\xcc\a\n" + "\x04Disk\x12\x0e\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x17\n" + @@ -929,7 +999,9 @@ const file_rookery_v1_disk_proto_rawDesc = "" + "\alocator\x18\x14 \x01(\tR\alocator\x12%\n" + "\x0edeletion_state\x18\x15 \x01(\tR\rdeletionState\x12%\n" + "\x0edeletion_error\x18\x16 \x01(\tR\rdeletionError\x12\x1a\n" + - "\bprotocol\x18\x17 \x01(\tR\bprotocolB\x15\n" + + "\bprotocol\x18\x17 \x01(\tR\bprotocol\x12\x19\n" + + "\bread_bps\x18\x18 \x01(\x03R\areadBps\x12\x1b\n" + + "\twrite_bps\x18\x19 \x01(\x03R\bwriteBpsB\x15\n" + "\x13_storage_backend_idB\x11\n" + "\x0f_attached_vm_idB\x13\n" + "\x11_template_disk_id\"\xa1\x02\n" + @@ -950,7 +1022,7 @@ const file_rookery_v1_disk_proto_rawDesc = "" + "\x11ListDisksResponse\x12&\n" + "\x05disks\x18\x01 \x03(\v2\x10.rookery.v1.DiskR\x05disks\")\n" + "\x0eGetDiskRequest\x12\x17\n" + - "\x02id\x18\x01 \x01(\x03B\a\xfaB\x04\"\x02 \x00R\x02id\"\xc5\x04\n" + + "\x02id\x18\x01 \x01(\x03B\a\xfaB\x04\"\x02 \x00R\x02id\"\xb4\x05\n" + "\x11CreateDiskRequest\x12;\n" + "\x04name\x18\x01 \x01(\tB'\xfaB$r\"\x10\x01\x18\x7f2\x1c^[A-Za-z0-9][A-Za-z0-9._-]*$R\x04name\x12)\n" + "\asize_gb\x18\x02 \x01(\x05B\v\xfaB\b\x1a\x06\x18\x80\x80\x04(\x01H\x00R\x06sizeGb\x88\x01\x01\x12:\n" + @@ -963,24 +1035,35 @@ const file_rookery_v1_disk_proto_rawDesc = "" + "\xfaB\ar\x05\x18 \xd0\x01\x01R\x13provisioningTimeout\x12;\n" + "\fprovisioning\x18\b \x01(\v2\x17.google.protobuf.StructR\fprovisioning\x12,\n" + "\vhost_device\x18\t \x01(\tB\v\xfaB\br\x06\x18\x80 \xd0\x01\x01R\n" + - "hostDeviceB\n" + + "hostDevice\x12'\n" + + "\bread_bps\x18\n" + + " \x01(\x03B\a\xfaB\x04\"\x02(\x00H\x04R\areadBps\x88\x01\x01\x12)\n" + + "\twrite_bps\x18\v \x01(\x03B\a\xfaB\x04\"\x02(\x00H\x05R\bwriteBps\x88\x01\x01B\n" + "\n" + "\b_size_gbB\x15\n" + "\x13_storage_backend_idB\x13\n" + "\x11_template_disk_idB\x0e\n" + - "\f_is_template\"F\n" + + "\f_is_templateB\v\n" + + "\t_read_bpsB\f\n" + + "\n" + + "_write_bps\"F\n" + "\x12DeleteDiskResponse\x12\x1a\n" + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x14\n" + - "\x05state\x18\x02 \x01(\tR\x05state\"\x94\x02\n" + + "\x05state\x18\x02 \x01(\tR\x05state\"\x83\x03\n" + "\x11UpdateDiskRequest\x12\x17\n" + "\x02id\x18\x01 \x01(\x03B\a\xfaB\x04\"\x02 \x00R\x02id\x12$\n" + "\vis_template\x18\x02 \x01(\bH\x00R\n" + "isTemplate\x88\x01\x01\x12>\n" + "\x13provisioning_script\x18\x03 \x01(\tB\b\xfaB\x05r\x03\x18\x80 H\x01R\x12provisioningScript\x88\x01\x01\x12?\n" + - "\x14provisioning_timeout\x18\x04 \x01(\tB\a\xfaB\x04r\x02\x18 H\x02R\x13provisioningTimeout\x88\x01\x01B\x0e\n" + + "\x14provisioning_timeout\x18\x04 \x01(\tB\a\xfaB\x04r\x02\x18 H\x02R\x13provisioningTimeout\x88\x01\x01\x12'\n" + + "\bread_bps\x18\x05 \x01(\x03B\a\xfaB\x04\"\x02(\x00H\x03R\areadBps\x88\x01\x01\x12)\n" + + "\twrite_bps\x18\x06 \x01(\x03B\a\xfaB\x04\"\x02(\x00H\x04R\bwriteBps\x88\x01\x01B\x0e\n" + "\f_is_templateB\x16\n" + "\x14_provisioning_scriptB\x17\n" + - "\x15_provisioning_timeout\",\n" + + "\x15_provisioning_timeoutB\v\n" + + "\t_read_bpsB\f\n" + + "\n" + + "_write_bps\",\n" + "\x11DeleteDiskRequest\x12\x17\n" + "\x02id\x18\x01 \x01(\x03B\a\xfaB\x04\"\x02 \x00R\x02id2\xdb\x03\n" + "\vDiskService\x12c\n" + diff --git a/design/gen/rookery/v1/disk.pb.validate.go b/design/gen/rookery/v1/disk.pb.validate.go index a6ea034..0872a29 100644 --- a/design/gen/rookery/v1/disk.pb.validate.go +++ b/design/gen/rookery/v1/disk.pb.validate.go @@ -78,6 +78,10 @@ func (m *VMDisk) validate(all bool) error { // no validation rules for Protocol + // no validation rules for ReadBps + + // no validation rules for WriteBps + if m.StorageBackendId != nil { // no validation rules for StorageBackendId } @@ -274,6 +278,10 @@ func (m *Disk) validate(all bool) error { // no validation rules for Protocol + // no validation rules for ReadBps + + // no validation rules for WriteBps + if m.StorageBackendId != nil { // no validation rules for StorageBackendId } @@ -995,6 +1003,36 @@ func (m *CreateDiskRequest) validate(all bool) error { // no validation rules for IsTemplate } + if m.ReadBps != nil { + + if m.GetReadBps() < 0 { + err := CreateDiskRequestValidationError{ + field: "ReadBps", + reason: "value must be greater than or equal to 0", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if m.WriteBps != nil { + + if m.GetWriteBps() < 0 { + err := CreateDiskRequestValidationError{ + field: "WriteBps", + reason: "value must be greater than or equal to 0", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + if len(errors) > 0 { return CreateDiskRequestMultiError(errors) } @@ -1250,6 +1288,36 @@ func (m *UpdateDiskRequest) validate(all bool) error { } + if m.ReadBps != nil { + + if m.GetReadBps() < 0 { + err := UpdateDiskRequestValidationError{ + field: "ReadBps", + reason: "value must be greater than or equal to 0", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if m.WriteBps != nil { + + if m.GetWriteBps() < 0 { + err := UpdateDiskRequestValidationError{ + field: "WriteBps", + reason: "value must be greater than or equal to 0", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + if len(errors) > 0 { return UpdateDiskRequestMultiError(errors) } diff --git a/design/gen/rookery/v1/storage_backend.pb.go b/design/gen/rookery/v1/storage_backend.pb.go index 5aa7a36..fbe2348 100644 --- a/design/gen/rookery/v1/storage_backend.pb.go +++ b/design/gen/rookery/v1/storage_backend.pb.go @@ -152,8 +152,13 @@ type KoujaConfig struct { ServerName string `protobuf:"bytes,8,opt,name=server_name,json=serverName,proto3" json:"server_name,omitempty"` Portal string `protobuf:"bytes,9,opt,name=portal,proto3" json:"portal,omitempty"` HostNqn string `protobuf:"bytes,10,opt,name=host_nqn,json=hostNqn,proto3" json:"host_nqn,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // PEM content is preferred when set. ca_file/client_cert_file/client_key_file + // remain for existing backends and CLI/Terraform configurations. + CaPem string `protobuf:"bytes,11,opt,name=ca_pem,json=caPem,proto3" json:"ca_pem,omitempty"` + ClientCertPem string `protobuf:"bytes,12,opt,name=client_cert_pem,json=clientCertPem,proto3" json:"client_cert_pem,omitempty"` + ClientKeyPem string `protobuf:"bytes,13,opt,name=client_key_pem,json=clientKeyPem,proto3" json:"client_key_pem,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *KoujaConfig) Reset() { @@ -256,6 +261,27 @@ func (x *KoujaConfig) GetHostNqn() string { return "" } +func (x *KoujaConfig) GetCaPem() string { + if x != nil { + return x.CaPem + } + return "" +} + +func (x *KoujaConfig) GetClientCertPem() string { + if x != nil { + return x.ClientCertPem + } + return "" +} + +func (x *KoujaConfig) GetClientKeyPem() string { + if x != nil { + return x.ClientKeyPem + } + return "" +} + type StorageBackend struct { state protoimpl.MessageState `protogen:"open.v1"` Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` @@ -1188,6 +1214,134 @@ func (x *StorageClassCapacity) GetMaxVolumeBytes() int64 { return 0 } +type ProbeKoujaBackendRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *KoujaConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProbeKoujaBackendRequest) Reset() { + *x = ProbeKoujaBackendRequest{} + mi := &file_rookery_v1_storage_backend_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProbeKoujaBackendRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProbeKoujaBackendRequest) ProtoMessage() {} + +func (x *ProbeKoujaBackendRequest) ProtoReflect() protoreflect.Message { + mi := &file_rookery_v1_storage_backend_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProbeKoujaBackendRequest.ProtoReflect.Descriptor instead. +func (*ProbeKoujaBackendRequest) Descriptor() ([]byte, []int) { + return file_rookery_v1_storage_backend_proto_rawDescGZIP(), []int{17} +} + +func (x *ProbeKoujaBackendRequest) GetConfig() *KoujaConfig { + if x != nil { + return x.Config + } + return nil +} + +type ProbeKoujaBackendResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reachable bool `protobuf:"varint,1,opt,name=reachable,proto3" json:"reachable,omitempty"` + Ready bool `protobuf:"varint,2,opt,name=ready,proto3" json:"ready,omitempty"` + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + Owner string `protobuf:"bytes,4,opt,name=owner,proto3" json:"owner,omitempty"` + Protocols []string `protobuf:"bytes,5,rep,name=protocols,proto3" json:"protocols,omitempty"` + StorageClasses []string `protobuf:"bytes,6,rep,name=storage_classes,json=storageClasses,proto3" json:"storage_classes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProbeKoujaBackendResponse) Reset() { + *x = ProbeKoujaBackendResponse{} + mi := &file_rookery_v1_storage_backend_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProbeKoujaBackendResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProbeKoujaBackendResponse) ProtoMessage() {} + +func (x *ProbeKoujaBackendResponse) ProtoReflect() protoreflect.Message { + mi := &file_rookery_v1_storage_backend_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProbeKoujaBackendResponse.ProtoReflect.Descriptor instead. +func (*ProbeKoujaBackendResponse) Descriptor() ([]byte, []int) { + return file_rookery_v1_storage_backend_proto_rawDescGZIP(), []int{18} +} + +func (x *ProbeKoujaBackendResponse) GetReachable() bool { + if x != nil { + return x.Reachable + } + return false +} + +func (x *ProbeKoujaBackendResponse) GetReady() bool { + if x != nil { + return x.Ready + } + return false +} + +func (x *ProbeKoujaBackendResponse) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *ProbeKoujaBackendResponse) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *ProbeKoujaBackendResponse) GetProtocols() []string { + if x != nil { + return x.Protocols + } + return nil +} + +func (x *ProbeKoujaBackendResponse) GetStorageClasses() []string { + if x != nil { + return x.StorageClasses + } + return nil +} + var File_rookery_v1_storage_backend_proto protoreflect.FileDescriptor const file_rookery_v1_storage_backend_proto_rawDesc = "" + @@ -1202,7 +1356,7 @@ const file_rookery_v1_storage_backend_proto_rawDesc = "" + "\x13preparation_timeout\x18\x05 \x01(\tB\a\xfaB\x04r\x02\x18 R\x12preparationTimeout\x12H\n" + "!allow_template_delete_with_clones\x18\x06 \x01(\bR\x1dallowTemplateDeleteWithClones\x12/\n" + "\x05kouja\x18\a \x01(\v2\x17.rookery.v1.KoujaConfigH\x00R\x05koujaB\x0f\n" + - "\rdriver_config\"\xbb\x03\n" + + "\rdriver_config\"\xc1\x04\n" + "\vKoujaConfig\x12$\n" + "\bendpoint\x18\x01 \x01(\tB\b\xfaB\x05r\x03\x18\x80\x04R\bendpoint\x125\n" + "\bprotocol\x18\x02 \x01(\tB\x19\xfaB\x16r\x14R\x05iscsiR\bnvme-tcp\xd0\x01\x01R\bprotocol\x12,\n" + @@ -1215,7 +1369,10 @@ const file_rookery_v1_storage_backend_proto_rawDesc = "" + "serverName\x12 \n" + "\x06portal\x18\t \x01(\tB\b\xfaB\x05r\x03\x18\xff\x01R\x06portal\x12#\n" + "\bhost_nqn\x18\n" + - " \x01(\tB\b\xfaB\x05r\x03\x18\xdf\x01R\ahostNqn\"\xd3\x02\n" + + " \x01(\tB\b\xfaB\x05r\x03\x18\xdf\x01R\ahostNqn\x12 \n" + + "\x06ca_pem\x18\v \x01(\tB\t\xfaB\x06r\x04\x18\x80\x80\x04R\x05caPem\x121\n" + + "\x0fclient_cert_pem\x18\f \x01(\tB\t\xfaB\x06r\x04\x18\x80\x80\x04R\rclientCertPem\x12/\n" + + "\x0eclient_key_pem\x18\r \x01(\tB\t\xfaB\x06r\x04\x18\x80\x80\x04R\fclientKeyPem\"\xd3\x02\n" + "\x0eStorageBackend\x12\x0e\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + @@ -1287,8 +1444,16 @@ const file_rookery_v1_storage_backend_proto_rawDesc = "" + "\n" + "free_bytes\x18\x05 \x01(\x03R\tfreeBytes\x12+\n" + "\x11allocatable_bytes\x18\x06 \x01(\x03R\x10allocatableBytes\x12(\n" + - "\x10max_volume_bytes\x18\a \x01(\x03R\x0emaxVolumeBytes2\x87\n" + - "\n" + + "\x10max_volume_bytes\x18\a \x01(\x03R\x0emaxVolumeBytes\"U\n" + + "\x18ProbeKoujaBackendRequest\x129\n" + + "\x06config\x18\x01 \x01(\v2\x17.rookery.v1.KoujaConfigB\b\xfaB\x05\x8a\x01\x02\x10\x01R\x06config\"\xc4\x01\n" + + "\x19ProbeKoujaBackendResponse\x12\x1c\n" + + "\treachable\x18\x01 \x01(\bR\treachable\x12\x14\n" + + "\x05ready\x18\x02 \x01(\bR\x05ready\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\x12\x14\n" + + "\x05owner\x18\x04 \x01(\tR\x05owner\x12\x1c\n" + + "\tprotocols\x18\x05 \x03(\tR\tprotocols\x12'\n" + + "\x0fstorage_classes\x18\x06 \x03(\tR\x0estorageClasses2\x98\v\n" + "\x15StorageBackendService\x12\x8f\x01\n" + "\x13ListStorageBackends\x12&.rookery.v1.ListStorageBackendsRequest\x1a'.rookery.v1.ListStorageBackendsResponse\"'\x82\xd3\xe4\x93\x02!b\bbackends\x12\x15/api/storage-backends\x12y\n" + "\x11GetStorageBackend\x12$.rookery.v1.GetStorageBackendRequest\x1a\x1a.rookery.v1.StorageBackend\"\"\x82\xd3\xe4\x93\x02\x1c\x12\x1a/api/storage-backends/{id}\x12}\n" + @@ -1298,7 +1463,8 @@ const file_rookery_v1_storage_backend_proto_rawDesc = "" + "\x13ListImportableDisks\x12&.rookery.v1.ListImportableDisksRequest\x1a'.rookery.v1.ListImportableDisksResponse\":\x82\xd3\xe4\x93\x024b\x05disks\x12+/api/storage-backends/{id}/importable-disks\x12\x89\x01\n" + "\vImportDisks\x12\x1e.rookery.v1.ImportDisksRequest\x1a\x1f.rookery.v1.ImportDisksResponse\"9\x82\xd3\xe4\x93\x023:\x01*b\x05disks\"'/api/storage-backends/{id}/import-disks\x12\x92\x01\n" + "\x17GetStorageBackendHealth\x12*.rookery.v1.GetStorageBackendHealthRequest\x1a .rookery.v1.StorageBackendHealth\")\x82\xd3\xe4\x93\x02#\x12!/api/storage-backends/{id}/health\x12\x99\x01\n" + - "\x1aTestStorageBackendDataPath\x12-.rookery.v1.TestStorageBackendDataPathRequest\x1a .rookery.v1.StorageBackendHealth\"*\x82\xd3\xe4\x93\x02$:\x01*\"\x1f/api/storage-backends/{id}/testB?Z=github.com/voidstarit/rookery/design/gen/rookery/v1;rookeryv1b\x06proto3" + "\x1aTestStorageBackendDataPath\x12-.rookery.v1.TestStorageBackendDataPathRequest\x1a .rookery.v1.StorageBackendHealth\"*\x82\xd3\xe4\x93\x02$:\x01*\"\x1f/api/storage-backends/{id}/test\x12\x8e\x01\n" + + "\x11ProbeKoujaBackend\x12$.rookery.v1.ProbeKoujaBackendRequest\x1a%.rookery.v1.ProbeKoujaBackendResponse\",\x82\xd3\xe4\x93\x02&:\x01*\"!/api/storage-backends/kouja/probeB?Z=github.com/voidstarit/rookery/design/gen/rookery/v1;rookeryv1b\x06proto3" var ( file_rookery_v1_storage_backend_proto_rawDescOnce sync.Once @@ -1312,7 +1478,7 @@ func file_rookery_v1_storage_backend_proto_rawDescGZIP() []byte { return file_rookery_v1_storage_backend_proto_rawDescData } -var file_rookery_v1_storage_backend_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_rookery_v1_storage_backend_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_rookery_v1_storage_backend_proto_goTypes = []any{ (*StorageBackendConfig)(nil), // 0: rookery.v1.StorageBackendConfig (*KoujaConfig)(nil), // 1: rookery.v1.KoujaConfig @@ -1331,46 +1497,51 @@ var file_rookery_v1_storage_backend_proto_goTypes = []any{ (*TestStorageBackendDataPathRequest)(nil), // 14: rookery.v1.TestStorageBackendDataPathRequest (*StorageBackendHealth)(nil), // 15: rookery.v1.StorageBackendHealth (*StorageClassCapacity)(nil), // 16: rookery.v1.StorageClassCapacity - (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp - (*ImportableDisk)(nil), // 18: rookery.v1.ImportableDisk - (*Disk)(nil), // 19: rookery.v1.Disk - (*emptypb.Empty)(nil), // 20: google.protobuf.Empty + (*ProbeKoujaBackendRequest)(nil), // 17: rookery.v1.ProbeKoujaBackendRequest + (*ProbeKoujaBackendResponse)(nil), // 18: rookery.v1.ProbeKoujaBackendResponse + (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp + (*ImportableDisk)(nil), // 20: rookery.v1.ImportableDisk + (*Disk)(nil), // 21: rookery.v1.Disk + (*emptypb.Empty)(nil), // 22: google.protobuf.Empty } var file_rookery_v1_storage_backend_proto_depIdxs = []int32{ 1, // 0: rookery.v1.StorageBackendConfig.kouja:type_name -> rookery.v1.KoujaConfig 0, // 1: rookery.v1.StorageBackend.config:type_name -> rookery.v1.StorageBackendConfig - 17, // 2: rookery.v1.StorageBackend.created_at:type_name -> google.protobuf.Timestamp - 17, // 3: rookery.v1.StorageBackend.updated_at:type_name -> google.protobuf.Timestamp + 19, // 2: rookery.v1.StorageBackend.created_at:type_name -> google.protobuf.Timestamp + 19, // 3: rookery.v1.StorageBackend.updated_at:type_name -> google.protobuf.Timestamp 2, // 4: rookery.v1.ListStorageBackendsResponse.backends:type_name -> rookery.v1.StorageBackend 0, // 5: rookery.v1.CreateStorageBackendRequest.config:type_name -> rookery.v1.StorageBackendConfig 0, // 6: rookery.v1.UpdateStorageBackendRequest.config:type_name -> rookery.v1.StorageBackendConfig - 18, // 7: rookery.v1.ListImportableDisksResponse.disks:type_name -> rookery.v1.ImportableDisk - 19, // 8: rookery.v1.ImportDisksResponse.disks:type_name -> rookery.v1.Disk - 17, // 9: rookery.v1.StorageBackendHealth.checked_at:type_name -> google.protobuf.Timestamp + 20, // 7: rookery.v1.ListImportableDisksResponse.disks:type_name -> rookery.v1.ImportableDisk + 21, // 8: rookery.v1.ImportDisksResponse.disks:type_name -> rookery.v1.Disk + 19, // 9: rookery.v1.StorageBackendHealth.checked_at:type_name -> google.protobuf.Timestamp 16, // 10: rookery.v1.StorageBackendHealth.classes:type_name -> rookery.v1.StorageClassCapacity - 3, // 11: rookery.v1.StorageBackendService.ListStorageBackends:input_type -> rookery.v1.ListStorageBackendsRequest - 5, // 12: rookery.v1.StorageBackendService.GetStorageBackend:input_type -> rookery.v1.GetStorageBackendRequest - 6, // 13: rookery.v1.StorageBackendService.CreateStorageBackend:input_type -> rookery.v1.CreateStorageBackendRequest - 7, // 14: rookery.v1.StorageBackendService.UpdateStorageBackend:input_type -> rookery.v1.UpdateStorageBackendRequest - 8, // 15: rookery.v1.StorageBackendService.DeleteStorageBackend:input_type -> rookery.v1.DeleteStorageBackendRequest - 9, // 16: rookery.v1.StorageBackendService.ListImportableDisks:input_type -> rookery.v1.ListImportableDisksRequest - 11, // 17: rookery.v1.StorageBackendService.ImportDisks:input_type -> rookery.v1.ImportDisksRequest - 13, // 18: rookery.v1.StorageBackendService.GetStorageBackendHealth:input_type -> rookery.v1.GetStorageBackendHealthRequest - 14, // 19: rookery.v1.StorageBackendService.TestStorageBackendDataPath:input_type -> rookery.v1.TestStorageBackendDataPathRequest - 4, // 20: rookery.v1.StorageBackendService.ListStorageBackends:output_type -> rookery.v1.ListStorageBackendsResponse - 2, // 21: rookery.v1.StorageBackendService.GetStorageBackend:output_type -> rookery.v1.StorageBackend - 2, // 22: rookery.v1.StorageBackendService.CreateStorageBackend:output_type -> rookery.v1.StorageBackend - 2, // 23: rookery.v1.StorageBackendService.UpdateStorageBackend:output_type -> rookery.v1.StorageBackend - 20, // 24: rookery.v1.StorageBackendService.DeleteStorageBackend:output_type -> google.protobuf.Empty - 10, // 25: rookery.v1.StorageBackendService.ListImportableDisks:output_type -> rookery.v1.ListImportableDisksResponse - 12, // 26: rookery.v1.StorageBackendService.ImportDisks:output_type -> rookery.v1.ImportDisksResponse - 15, // 27: rookery.v1.StorageBackendService.GetStorageBackendHealth:output_type -> rookery.v1.StorageBackendHealth - 15, // 28: rookery.v1.StorageBackendService.TestStorageBackendDataPath:output_type -> rookery.v1.StorageBackendHealth - 20, // [20:29] is the sub-list for method output_type - 11, // [11:20] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 1, // 11: rookery.v1.ProbeKoujaBackendRequest.config:type_name -> rookery.v1.KoujaConfig + 3, // 12: rookery.v1.StorageBackendService.ListStorageBackends:input_type -> rookery.v1.ListStorageBackendsRequest + 5, // 13: rookery.v1.StorageBackendService.GetStorageBackend:input_type -> rookery.v1.GetStorageBackendRequest + 6, // 14: rookery.v1.StorageBackendService.CreateStorageBackend:input_type -> rookery.v1.CreateStorageBackendRequest + 7, // 15: rookery.v1.StorageBackendService.UpdateStorageBackend:input_type -> rookery.v1.UpdateStorageBackendRequest + 8, // 16: rookery.v1.StorageBackendService.DeleteStorageBackend:input_type -> rookery.v1.DeleteStorageBackendRequest + 9, // 17: rookery.v1.StorageBackendService.ListImportableDisks:input_type -> rookery.v1.ListImportableDisksRequest + 11, // 18: rookery.v1.StorageBackendService.ImportDisks:input_type -> rookery.v1.ImportDisksRequest + 13, // 19: rookery.v1.StorageBackendService.GetStorageBackendHealth:input_type -> rookery.v1.GetStorageBackendHealthRequest + 14, // 20: rookery.v1.StorageBackendService.TestStorageBackendDataPath:input_type -> rookery.v1.TestStorageBackendDataPathRequest + 17, // 21: rookery.v1.StorageBackendService.ProbeKoujaBackend:input_type -> rookery.v1.ProbeKoujaBackendRequest + 4, // 22: rookery.v1.StorageBackendService.ListStorageBackends:output_type -> rookery.v1.ListStorageBackendsResponse + 2, // 23: rookery.v1.StorageBackendService.GetStorageBackend:output_type -> rookery.v1.StorageBackend + 2, // 24: rookery.v1.StorageBackendService.CreateStorageBackend:output_type -> rookery.v1.StorageBackend + 2, // 25: rookery.v1.StorageBackendService.UpdateStorageBackend:output_type -> rookery.v1.StorageBackend + 22, // 26: rookery.v1.StorageBackendService.DeleteStorageBackend:output_type -> google.protobuf.Empty + 10, // 27: rookery.v1.StorageBackendService.ListImportableDisks:output_type -> rookery.v1.ListImportableDisksResponse + 12, // 28: rookery.v1.StorageBackendService.ImportDisks:output_type -> rookery.v1.ImportDisksResponse + 15, // 29: rookery.v1.StorageBackendService.GetStorageBackendHealth:output_type -> rookery.v1.StorageBackendHealth + 15, // 30: rookery.v1.StorageBackendService.TestStorageBackendDataPath:output_type -> rookery.v1.StorageBackendHealth + 18, // 31: rookery.v1.StorageBackendService.ProbeKoujaBackend:output_type -> rookery.v1.ProbeKoujaBackendResponse + 22, // [22:32] is the sub-list for method output_type + 12, // [12:22] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_rookery_v1_storage_backend_proto_init() } @@ -1388,7 +1559,7 @@ func file_rookery_v1_storage_backend_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_rookery_v1_storage_backend_proto_rawDesc), len(file_rookery_v1_storage_backend_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 19, NumExtensions: 0, NumServices: 1, }, diff --git a/design/gen/rookery/v1/storage_backend.pb.gw.go b/design/gen/rookery/v1/storage_backend.pb.gw.go index ca9d81a..abb9646 100644 --- a/design/gen/rookery/v1/storage_backend.pb.gw.go +++ b/design/gen/rookery/v1/storage_backend.pb.gw.go @@ -374,6 +374,33 @@ func local_request_StorageBackendService_TestStorageBackendDataPath_0(ctx contex return msg, metadata, err } +func request_StorageBackendService_ProbeKoujaBackend_0(ctx context.Context, marshaler runtime.Marshaler, client StorageBackendServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ProbeKoujaBackendRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ProbeKoujaBackend(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_StorageBackendService_ProbeKoujaBackend_0(ctx context.Context, marshaler runtime.Marshaler, server StorageBackendServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ProbeKoujaBackendRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ProbeKoujaBackend(ctx, &protoReq) + return msg, metadata, err +} + // RegisterStorageBackendServiceHandlerServer registers the http handlers for service StorageBackendService to "mux". // UnaryRPC :call StorageBackendServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -560,6 +587,26 @@ func RegisterStorageBackendServiceHandlerServer(ctx context.Context, mux *runtim } forward_StorageBackendService_TestStorageBackendDataPath_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_StorageBackendService_ProbeKoujaBackend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/rookery.v1.StorageBackendService/ProbeKoujaBackend", runtime.WithHTTPPathPattern("/api/storage-backends/kouja/probe")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_StorageBackendService_ProbeKoujaBackend_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_StorageBackendService_ProbeKoujaBackend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -753,6 +800,23 @@ func RegisterStorageBackendServiceHandlerClient(ctx context.Context, mux *runtim } forward_StorageBackendService_TestStorageBackendDataPath_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_StorageBackendService_ProbeKoujaBackend_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/rookery.v1.StorageBackendService/ProbeKoujaBackend", runtime.WithHTTPPathPattern("/api/storage-backends/kouja/probe")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_StorageBackendService_ProbeKoujaBackend_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_StorageBackendService_ProbeKoujaBackend_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -793,6 +857,7 @@ var ( pattern_StorageBackendService_ImportDisks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "storage-backends", "id", "import-disks"}, "")) pattern_StorageBackendService_GetStorageBackendHealth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "storage-backends", "id", "health"}, "")) pattern_StorageBackendService_TestStorageBackendDataPath_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "storage-backends", "id", "test"}, "")) + pattern_StorageBackendService_ProbeKoujaBackend_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "storage-backends", "kouja", "probe"}, "")) ) var ( @@ -805,4 +870,5 @@ var ( forward_StorageBackendService_ImportDisks_0 = runtime.ForwardResponseMessage forward_StorageBackendService_GetStorageBackendHealth_0 = runtime.ForwardResponseMessage forward_StorageBackendService_TestStorageBackendDataPath_0 = runtime.ForwardResponseMessage + forward_StorageBackendService_ProbeKoujaBackend_0 = runtime.ForwardResponseMessage ) diff --git a/design/gen/rookery/v1/storage_backend.pb.validate.go b/design/gen/rookery/v1/storage_backend.pb.validate.go index 44aa715..3eeef17 100644 --- a/design/gen/rookery/v1/storage_backend.pb.validate.go +++ b/design/gen/rookery/v1/storage_backend.pb.validate.go @@ -367,6 +367,39 @@ func (m *KoujaConfig) validate(all bool) error { errors = append(errors, err) } + if utf8.RuneCountInString(m.GetCaPem()) > 65536 { + err := KoujaConfigValidationError{ + field: "CaPem", + reason: "value length must be at most 65536 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetClientCertPem()) > 65536 { + err := KoujaConfigValidationError{ + field: "ClientCertPem", + reason: "value length must be at most 65536 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetClientKeyPem()) > 65536 { + err := KoujaConfigValidationError{ + field: "ClientKeyPem", + reason: "value length must be at most 65536 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + if len(errors) > 0 { return KoujaConfigMultiError(errors) } @@ -2575,3 +2608,255 @@ var _ interface { Cause() error ErrorName() string } = StorageClassCapacityValidationError{} + +// Validate checks the field values on ProbeKoujaBackendRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ProbeKoujaBackendRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ProbeKoujaBackendRequest with the +// rules defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ProbeKoujaBackendRequestMultiError, or nil if none found. +func (m *ProbeKoujaBackendRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ProbeKoujaBackendRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if m.GetConfig() == nil { + err := ProbeKoujaBackendRequestValidationError{ + field: "Config", + reason: "value is required", + } + if !all { + return err + } + errors = append(errors, err) + } + + if all { + switch v := interface{}(m.GetConfig()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ProbeKoujaBackendRequestValidationError{ + field: "Config", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ProbeKoujaBackendRequestValidationError{ + field: "Config", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetConfig()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ProbeKoujaBackendRequestValidationError{ + field: "Config", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return ProbeKoujaBackendRequestMultiError(errors) + } + + return nil +} + +// ProbeKoujaBackendRequestMultiError is an error wrapping multiple validation +// errors returned by ProbeKoujaBackendRequest.ValidateAll() if the designated +// constraints aren't met. +type ProbeKoujaBackendRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ProbeKoujaBackendRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ProbeKoujaBackendRequestMultiError) AllErrors() []error { return m } + +// ProbeKoujaBackendRequestValidationError is the validation error returned by +// ProbeKoujaBackendRequest.Validate if the designated constraints aren't met. +type ProbeKoujaBackendRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ProbeKoujaBackendRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ProbeKoujaBackendRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ProbeKoujaBackendRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ProbeKoujaBackendRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ProbeKoujaBackendRequestValidationError) ErrorName() string { + return "ProbeKoujaBackendRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e ProbeKoujaBackendRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sProbeKoujaBackendRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ProbeKoujaBackendRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ProbeKoujaBackendRequestValidationError{} + +// Validate checks the field values on ProbeKoujaBackendResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ProbeKoujaBackendResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ProbeKoujaBackendResponse with the +// rules defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ProbeKoujaBackendResponseMultiError, or nil if none found. +func (m *ProbeKoujaBackendResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ProbeKoujaBackendResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Reachable + + // no validation rules for Ready + + // no validation rules for Detail + + // no validation rules for Owner + + if len(errors) > 0 { + return ProbeKoujaBackendResponseMultiError(errors) + } + + return nil +} + +// ProbeKoujaBackendResponseMultiError is an error wrapping multiple validation +// errors returned by ProbeKoujaBackendResponse.ValidateAll() if the +// designated constraints aren't met. +type ProbeKoujaBackendResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ProbeKoujaBackendResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ProbeKoujaBackendResponseMultiError) AllErrors() []error { return m } + +// ProbeKoujaBackendResponseValidationError is the validation error returned by +// ProbeKoujaBackendResponse.Validate if the designated constraints aren't met. +type ProbeKoujaBackendResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ProbeKoujaBackendResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ProbeKoujaBackendResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ProbeKoujaBackendResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ProbeKoujaBackendResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ProbeKoujaBackendResponseValidationError) ErrorName() string { + return "ProbeKoujaBackendResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ProbeKoujaBackendResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sProbeKoujaBackendResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ProbeKoujaBackendResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ProbeKoujaBackendResponseValidationError{} diff --git a/design/gen/rookery/v1/storage_backend_grpc.pb.go b/design/gen/rookery/v1/storage_backend_grpc.pb.go index db43258..1a3cdd0 100644 --- a/design/gen/rookery/v1/storage_backend_grpc.pb.go +++ b/design/gen/rookery/v1/storage_backend_grpc.pb.go @@ -29,6 +29,7 @@ const ( StorageBackendService_ImportDisks_FullMethodName = "/rookery.v1.StorageBackendService/ImportDisks" StorageBackendService_GetStorageBackendHealth_FullMethodName = "/rookery.v1.StorageBackendService/GetStorageBackendHealth" StorageBackendService_TestStorageBackendDataPath_FullMethodName = "/rookery.v1.StorageBackendService/TestStorageBackendDataPath" + StorageBackendService_ProbeKoujaBackend_FullMethodName = "/rookery.v1.StorageBackendService/ProbeKoujaBackend" ) // StorageBackendServiceClient is the client API for StorageBackendService service. @@ -46,6 +47,7 @@ type StorageBackendServiceClient interface { ImportDisks(ctx context.Context, in *ImportDisksRequest, opts ...grpc.CallOption) (*ImportDisksResponse, error) GetStorageBackendHealth(ctx context.Context, in *GetStorageBackendHealthRequest, opts ...grpc.CallOption) (*StorageBackendHealth, error) TestStorageBackendDataPath(ctx context.Context, in *TestStorageBackendDataPathRequest, opts ...grpc.CallOption) (*StorageBackendHealth, error) + ProbeKoujaBackend(ctx context.Context, in *ProbeKoujaBackendRequest, opts ...grpc.CallOption) (*ProbeKoujaBackendResponse, error) } type storageBackendServiceClient struct { @@ -146,6 +148,16 @@ func (c *storageBackendServiceClient) TestStorageBackendDataPath(ctx context.Con return out, nil } +func (c *storageBackendServiceClient) ProbeKoujaBackend(ctx context.Context, in *ProbeKoujaBackendRequest, opts ...grpc.CallOption) (*ProbeKoujaBackendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProbeKoujaBackendResponse) + err := c.cc.Invoke(ctx, StorageBackendService_ProbeKoujaBackend_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // StorageBackendServiceServer is the server API for StorageBackendService service. // All implementations must embed UnimplementedStorageBackendServiceServer // for forward compatibility. @@ -161,6 +173,7 @@ type StorageBackendServiceServer interface { ImportDisks(context.Context, *ImportDisksRequest) (*ImportDisksResponse, error) GetStorageBackendHealth(context.Context, *GetStorageBackendHealthRequest) (*StorageBackendHealth, error) TestStorageBackendDataPath(context.Context, *TestStorageBackendDataPathRequest) (*StorageBackendHealth, error) + ProbeKoujaBackend(context.Context, *ProbeKoujaBackendRequest) (*ProbeKoujaBackendResponse, error) mustEmbedUnimplementedStorageBackendServiceServer() } @@ -198,6 +211,9 @@ func (UnimplementedStorageBackendServiceServer) GetStorageBackendHealth(context. func (UnimplementedStorageBackendServiceServer) TestStorageBackendDataPath(context.Context, *TestStorageBackendDataPathRequest) (*StorageBackendHealth, error) { return nil, status.Error(codes.Unimplemented, "method TestStorageBackendDataPath not implemented") } +func (UnimplementedStorageBackendServiceServer) ProbeKoujaBackend(context.Context, *ProbeKoujaBackendRequest) (*ProbeKoujaBackendResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ProbeKoujaBackend not implemented") +} func (UnimplementedStorageBackendServiceServer) mustEmbedUnimplementedStorageBackendServiceServer() {} func (UnimplementedStorageBackendServiceServer) testEmbeddedByValue() {} @@ -381,6 +397,24 @@ func _StorageBackendService_TestStorageBackendDataPath_Handler(srv interface{}, return interceptor(ctx, in, info, handler) } +func _StorageBackendService_ProbeKoujaBackend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProbeKoujaBackendRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StorageBackendServiceServer).ProbeKoujaBackend(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StorageBackendService_ProbeKoujaBackend_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StorageBackendServiceServer).ProbeKoujaBackend(ctx, req.(*ProbeKoujaBackendRequest)) + } + return interceptor(ctx, in, info, handler) +} + // StorageBackendService_ServiceDesc is the grpc.ServiceDesc for StorageBackendService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -424,6 +458,10 @@ var StorageBackendService_ServiceDesc = grpc.ServiceDesc{ MethodName: "TestStorageBackendDataPath", Handler: _StorageBackendService_TestStorageBackendDataPath_Handler, }, + { + MethodName: "ProbeKoujaBackend", + Handler: _StorageBackendService_ProbeKoujaBackend_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "rookery/v1/storage_backend.proto", diff --git a/design/gen/rookery/v1/vm.pb.go b/design/gen/rookery/v1/vm.pb.go index 8b212ff..8ba4343 100644 --- a/design/gen/rookery/v1/vm.pb.go +++ b/design/gen/rookery/v1/vm.pb.go @@ -684,6 +684,50 @@ func (x *StartVMRequest) GetId() string { return "" } +type ResumeVMRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeVMRequest) Reset() { + *x = ResumeVMRequest{} + mi := &file_rookery_v1_vm_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeVMRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeVMRequest) ProtoMessage() {} + +func (x *ResumeVMRequest) ProtoReflect() protoreflect.Message { + mi := &file_rookery_v1_vm_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeVMRequest.ProtoReflect.Descriptor instead. +func (*ResumeVMRequest) Descriptor() ([]byte, []int) { + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{8} +} + +func (x *ResumeVMRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + type StopVMRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -693,7 +737,7 @@ type StopVMRequest struct { func (x *StopVMRequest) Reset() { *x = StopVMRequest{} - mi := &file_rookery_v1_vm_proto_msgTypes[8] + mi := &file_rookery_v1_vm_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -705,7 +749,7 @@ func (x *StopVMRequest) String() string { func (*StopVMRequest) ProtoMessage() {} func (x *StopVMRequest) ProtoReflect() protoreflect.Message { - mi := &file_rookery_v1_vm_proto_msgTypes[8] + mi := &file_rookery_v1_vm_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -718,7 +762,7 @@ func (x *StopVMRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopVMRequest.ProtoReflect.Descriptor instead. func (*StopVMRequest) Descriptor() ([]byte, []int) { - return file_rookery_v1_vm_proto_rawDescGZIP(), []int{8} + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{9} } func (x *StopVMRequest) GetId() string { @@ -737,7 +781,7 @@ type ResetVMRequest struct { func (x *ResetVMRequest) Reset() { *x = ResetVMRequest{} - mi := &file_rookery_v1_vm_proto_msgTypes[9] + mi := &file_rookery_v1_vm_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -749,7 +793,7 @@ func (x *ResetVMRequest) String() string { func (*ResetVMRequest) ProtoMessage() {} func (x *ResetVMRequest) ProtoReflect() protoreflect.Message { - mi := &file_rookery_v1_vm_proto_msgTypes[9] + mi := &file_rookery_v1_vm_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -762,7 +806,7 @@ func (x *ResetVMRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResetVMRequest.ProtoReflect.Descriptor instead. func (*ResetVMRequest) Descriptor() ([]byte, []int) { - return file_rookery_v1_vm_proto_rawDescGZIP(), []int{9} + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{10} } func (x *ResetVMRequest) GetId() string { @@ -781,7 +825,7 @@ type CtrlAltDelRequest struct { func (x *CtrlAltDelRequest) Reset() { *x = CtrlAltDelRequest{} - mi := &file_rookery_v1_vm_proto_msgTypes[10] + mi := &file_rookery_v1_vm_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -793,7 +837,7 @@ func (x *CtrlAltDelRequest) String() string { func (*CtrlAltDelRequest) ProtoMessage() {} func (x *CtrlAltDelRequest) ProtoReflect() protoreflect.Message { - mi := &file_rookery_v1_vm_proto_msgTypes[10] + mi := &file_rookery_v1_vm_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -806,7 +850,7 @@ func (x *CtrlAltDelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CtrlAltDelRequest.ProtoReflect.Descriptor instead. func (*CtrlAltDelRequest) Descriptor() ([]byte, []int) { - return file_rookery_v1_vm_proto_rawDescGZIP(), []int{10} + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{11} } func (x *CtrlAltDelRequest) GetId() string { @@ -825,7 +869,7 @@ type GetVMMetricsRequest struct { func (x *GetVMMetricsRequest) Reset() { *x = GetVMMetricsRequest{} - mi := &file_rookery_v1_vm_proto_msgTypes[11] + mi := &file_rookery_v1_vm_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -837,7 +881,7 @@ func (x *GetVMMetricsRequest) String() string { func (*GetVMMetricsRequest) ProtoMessage() {} func (x *GetVMMetricsRequest) ProtoReflect() protoreflect.Message { - mi := &file_rookery_v1_vm_proto_msgTypes[11] + mi := &file_rookery_v1_vm_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -850,7 +894,7 @@ func (x *GetVMMetricsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetVMMetricsRequest.ProtoReflect.Descriptor instead. func (*GetVMMetricsRequest) Descriptor() ([]byte, []int) { - return file_rookery_v1_vm_proto_rawDescGZIP(), []int{11} + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{12} } func (x *GetVMMetricsRequest) GetId() string { @@ -879,13 +923,14 @@ type VMMetrics struct { NetworkRxPacketsTotal uint64 `protobuf:"varint,15,opt,name=network_rx_packets_total,json=networkRxPacketsTotal,proto3" json:"network_rx_packets_total,omitempty"` NetworkTxPacketsTotal uint64 `protobuf:"varint,16,opt,name=network_tx_packets_total,json=networkTxPacketsTotal,proto3" json:"network_tx_packets_total,omitempty"` SampledAt *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=sampled_at,json=sampledAt,proto3" json:"sampled_at,omitempty"` + BlockDevices []*BlockDeviceMetrics `protobuf:"bytes,18,rep,name=block_devices,json=blockDevices,proto3" json:"block_devices,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *VMMetrics) Reset() { *x = VMMetrics{} - mi := &file_rookery_v1_vm_proto_msgTypes[12] + mi := &file_rookery_v1_vm_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -897,7 +942,7 @@ func (x *VMMetrics) String() string { func (*VMMetrics) ProtoMessage() {} func (x *VMMetrics) ProtoReflect() protoreflect.Message { - mi := &file_rookery_v1_vm_proto_msgTypes[12] + mi := &file_rookery_v1_vm_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -910,7 +955,7 @@ func (x *VMMetrics) ProtoReflect() protoreflect.Message { // Deprecated: Use VMMetrics.ProtoReflect.Descriptor instead. func (*VMMetrics) Descriptor() ([]byte, []int) { - return file_rookery_v1_vm_proto_rawDescGZIP(), []int{12} + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{13} } func (x *VMMetrics) GetRunning() bool { @@ -1032,6 +1077,137 @@ func (x *VMMetrics) GetSampledAt() *timestamppb.Timestamp { return nil } +func (x *VMMetrics) GetBlockDevices() []*BlockDeviceMetrics { + if x != nil { + return x.BlockDevices + } + return nil +} + +type BlockDeviceMetrics struct { + state protoimpl.MessageState `protogen:"open.v1"` + DiskId int64 `protobuf:"varint,1,opt,name=disk_id,json=diskId,proto3" json:"disk_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Device string `protobuf:"bytes,3,opt,name=device,proto3" json:"device,omitempty"` + ReadBytes uint64 `protobuf:"varint,4,opt,name=read_bytes,json=readBytes,proto3" json:"read_bytes,omitempty"` + WriteBytes uint64 `protobuf:"varint,5,opt,name=write_bytes,json=writeBytes,proto3" json:"write_bytes,omitempty"` + ReadOperations uint64 `protobuf:"varint,6,opt,name=read_operations,json=readOperations,proto3" json:"read_operations,omitempty"` + WriteOperations uint64 `protobuf:"varint,7,opt,name=write_operations,json=writeOperations,proto3" json:"write_operations,omitempty"` + FailedReadOperations uint64 `protobuf:"varint,8,opt,name=failed_read_operations,json=failedReadOperations,proto3" json:"failed_read_operations,omitempty"` + FailedWriteOperations uint64 `protobuf:"varint,9,opt,name=failed_write_operations,json=failedWriteOperations,proto3" json:"failed_write_operations,omitempty"` + InvalidReadOperations uint64 `protobuf:"varint,10,opt,name=invalid_read_operations,json=invalidReadOperations,proto3" json:"invalid_read_operations,omitempty"` + InvalidWriteOperations uint64 `protobuf:"varint,11,opt,name=invalid_write_operations,json=invalidWriteOperations,proto3" json:"invalid_write_operations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlockDeviceMetrics) Reset() { + *x = BlockDeviceMetrics{} + mi := &file_rookery_v1_vm_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlockDeviceMetrics) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlockDeviceMetrics) ProtoMessage() {} + +func (x *BlockDeviceMetrics) ProtoReflect() protoreflect.Message { + mi := &file_rookery_v1_vm_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlockDeviceMetrics.ProtoReflect.Descriptor instead. +func (*BlockDeviceMetrics) Descriptor() ([]byte, []int) { + return file_rookery_v1_vm_proto_rawDescGZIP(), []int{14} +} + +func (x *BlockDeviceMetrics) GetDiskId() int64 { + if x != nil { + return x.DiskId + } + return 0 +} + +func (x *BlockDeviceMetrics) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *BlockDeviceMetrics) GetDevice() string { + if x != nil { + return x.Device + } + return "" +} + +func (x *BlockDeviceMetrics) GetReadBytes() uint64 { + if x != nil { + return x.ReadBytes + } + return 0 +} + +func (x *BlockDeviceMetrics) GetWriteBytes() uint64 { + if x != nil { + return x.WriteBytes + } + return 0 +} + +func (x *BlockDeviceMetrics) GetReadOperations() uint64 { + if x != nil { + return x.ReadOperations + } + return 0 +} + +func (x *BlockDeviceMetrics) GetWriteOperations() uint64 { + if x != nil { + return x.WriteOperations + } + return 0 +} + +func (x *BlockDeviceMetrics) GetFailedReadOperations() uint64 { + if x != nil { + return x.FailedReadOperations + } + return 0 +} + +func (x *BlockDeviceMetrics) GetFailedWriteOperations() uint64 { + if x != nil { + return x.FailedWriteOperations + } + return 0 +} + +func (x *BlockDeviceMetrics) GetInvalidReadOperations() uint64 { + if x != nil { + return x.InvalidReadOperations + } + return 0 +} + +func (x *BlockDeviceMetrics) GetInvalidWriteOperations() uint64 { + if x != nil { + return x.InvalidWriteOperations + } + return 0 +} + var File_rookery_v1_vm_proto protoreflect.FileDescriptor const file_rookery_v1_vm_proto_rawDesc = "" + @@ -1123,6 +1299,8 @@ const file_rookery_v1_vm_proto_rawDesc = "" + "\x0fDeleteVMRequest\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"*\n" + "\x0eStartVMRequest\x12\x18\n" + + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"+\n" + + "\x0fResumeVMRequest\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\")\n" + "\rStopVMRequest\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"*\n" + @@ -1131,7 +1309,7 @@ const file_rookery_v1_vm_proto_rawDesc = "" + "\x11CtrlAltDelRequest\x12\x18\n" + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"/\n" + "\x13GetVMMetricsRequest\x12\x18\n" + - "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"\xc1\x05\n" + + "\x02id\x18\x01 \x01(\tB\b\xfaB\x05r\x03\xb0\x01\x01R\x02id\"\x86\x06\n" + "\tVMMetrics\x12\x18\n" + "\arunning\x18\x01 \x01(\bR\arunning\x12\x10\n" + "\x03pid\x18\x02 \x01(\x05R\x03pid\x12%\n" + @@ -1151,7 +1329,23 @@ const file_rookery_v1_vm_proto_rawDesc = "" + "\x18network_rx_packets_total\x18\x0f \x01(\x04R\x15networkRxPacketsTotal\x127\n" + "\x18network_tx_packets_total\x18\x10 \x01(\x04R\x15networkTxPacketsTotal\x129\n" + "\n" + - "sampled_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\tsampledAt2\xea\x06\n" + + "sampled_at\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\tsampledAt\x12C\n" + + "\rblock_devices\x18\x12 \x03(\v2\x1e.rookery.v1.BlockDeviceMetricsR\fblockDevices\"\xcd\x03\n" + + "\x12BlockDeviceMetrics\x12\x17\n" + + "\adisk_id\x18\x01 \x01(\x03R\x06diskId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + + "\x06device\x18\x03 \x01(\tR\x06device\x12\x1d\n" + + "\n" + + "read_bytes\x18\x04 \x01(\x04R\treadBytes\x12\x1f\n" + + "\vwrite_bytes\x18\x05 \x01(\x04R\n" + + "writeBytes\x12'\n" + + "\x0fread_operations\x18\x06 \x01(\x04R\x0ereadOperations\x12)\n" + + "\x10write_operations\x18\a \x01(\x04R\x0fwriteOperations\x124\n" + + "\x16failed_read_operations\x18\b \x01(\x04R\x14failedReadOperations\x126\n" + + "\x17failed_write_operations\x18\t \x01(\x04R\x15failedWriteOperations\x126\n" + + "\x17invalid_read_operations\x18\n" + + " \x01(\x04R\x15invalidReadOperations\x128\n" + + "\x18invalid_write_operations\x18\v \x01(\x04R\x16invalidWriteOperations2\xc1\a\n" + "\tVMService\x12Y\n" + "\aListVMs\x12\x1a.rookery.v1.ListVMsRequest\x1a\x1b.rookery.v1.ListVMsResponse\"\x15\x82\xd3\xe4\x93\x02\x0fb\x03vms\x12\b/api/vms\x12H\n" + "\x05GetVM\x12\x18.rookery.v1.GetVMRequest\x1a\x0e.rookery.v1.VM\"\x15\x82\xd3\xe4\x93\x02\x0f\x12\r/api/vms/{id}\x12L\n" + @@ -1162,7 +1356,8 @@ const file_rookery_v1_vm_proto_rawDesc = "" + "\x06StopVM\x12\x19.rookery.v1.StopVMRequest\x1a\x0e.rookery.v1.VM\"\x1a\x82\xd3\xe4\x93\x02\x14\"\x12/api/vms/{id}/stop\x12R\n" + "\aResetVM\x12\x1a.rookery.v1.ResetVMRequest\x1a\x0e.rookery.v1.VM\"\x1b\x82\xd3\xe4\x93\x02\x15\"\x13/api/vms/{id}/reset\x12_\n" + "\n" + - "CtrlAltDel\x12\x1d.rookery.v1.CtrlAltDelRequest\x1a\x0e.rookery.v1.VM\"\"\x82\xd3\xe4\x93\x02\x1c\"\x1a/api/vms/{id}/ctrl-alt-del\x12e\n" + + "CtrlAltDel\x12\x1d.rookery.v1.CtrlAltDelRequest\x1a\x0e.rookery.v1.VM\"\"\x82\xd3\xe4\x93\x02\x1c\"\x1a/api/vms/{id}/ctrl-alt-del\x12U\n" + + "\bResumeVM\x12\x1b.rookery.v1.ResumeVMRequest\x1a\x0e.rookery.v1.VM\"\x1c\x82\xd3\xe4\x93\x02\x16\"\x14/api/vms/{id}/resume\x12e\n" + "\fGetVMMetrics\x12\x1f.rookery.v1.GetVMMetricsRequest\x1a\x15.rookery.v1.VMMetrics\"\x1d\x82\xd3\xe4\x93\x02\x17\x12\x15/api/vms/{id}/metricsB?Z=github.com/voidstarit/rookery/design/gen/rookery/v1;rookeryv1b\x06proto3" var ( @@ -1177,7 +1372,7 @@ func file_rookery_v1_vm_proto_rawDescGZIP() []byte { return file_rookery_v1_vm_proto_rawDescData } -var file_rookery_v1_vm_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_rookery_v1_vm_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_rookery_v1_vm_proto_goTypes = []any{ (*VM)(nil), // 0: rookery.v1.VM (*ListVMsRequest)(nil), // 1: rookery.v1.ListVMsRequest @@ -1187,51 +1382,56 @@ var file_rookery_v1_vm_proto_goTypes = []any{ (*UpdateVMRequest)(nil), // 5: rookery.v1.UpdateVMRequest (*DeleteVMRequest)(nil), // 6: rookery.v1.DeleteVMRequest (*StartVMRequest)(nil), // 7: rookery.v1.StartVMRequest - (*StopVMRequest)(nil), // 8: rookery.v1.StopVMRequest - (*ResetVMRequest)(nil), // 9: rookery.v1.ResetVMRequest - (*CtrlAltDelRequest)(nil), // 10: rookery.v1.CtrlAltDelRequest - (*GetVMMetricsRequest)(nil), // 11: rookery.v1.GetVMMetricsRequest - (*VMMetrics)(nil), // 12: rookery.v1.VMMetrics - (*VMDisk)(nil), // 13: rookery.v1.VMDisk - (*VMMedia)(nil), // 14: rookery.v1.VMMedia - (*VMInterface)(nil), // 15: rookery.v1.VMInterface - (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 17: google.protobuf.Empty + (*ResumeVMRequest)(nil), // 8: rookery.v1.ResumeVMRequest + (*StopVMRequest)(nil), // 9: rookery.v1.StopVMRequest + (*ResetVMRequest)(nil), // 10: rookery.v1.ResetVMRequest + (*CtrlAltDelRequest)(nil), // 11: rookery.v1.CtrlAltDelRequest + (*GetVMMetricsRequest)(nil), // 12: rookery.v1.GetVMMetricsRequest + (*VMMetrics)(nil), // 13: rookery.v1.VMMetrics + (*BlockDeviceMetrics)(nil), // 14: rookery.v1.BlockDeviceMetrics + (*VMDisk)(nil), // 15: rookery.v1.VMDisk + (*VMMedia)(nil), // 16: rookery.v1.VMMedia + (*VMInterface)(nil), // 17: rookery.v1.VMInterface + (*timestamppb.Timestamp)(nil), // 18: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 19: google.protobuf.Empty } var file_rookery_v1_vm_proto_depIdxs = []int32{ - 13, // 0: rookery.v1.VM.disk:type_name -> rookery.v1.VMDisk - 13, // 1: rookery.v1.VM.disks:type_name -> rookery.v1.VMDisk - 14, // 2: rookery.v1.VM.boot_media:type_name -> rookery.v1.VMMedia - 15, // 3: rookery.v1.VM.interfaces:type_name -> rookery.v1.VMInterface - 16, // 4: rookery.v1.VM.created_at:type_name -> google.protobuf.Timestamp - 16, // 5: rookery.v1.VM.updated_at:type_name -> google.protobuf.Timestamp + 15, // 0: rookery.v1.VM.disk:type_name -> rookery.v1.VMDisk + 15, // 1: rookery.v1.VM.disks:type_name -> rookery.v1.VMDisk + 16, // 2: rookery.v1.VM.boot_media:type_name -> rookery.v1.VMMedia + 17, // 3: rookery.v1.VM.interfaces:type_name -> rookery.v1.VMInterface + 18, // 4: rookery.v1.VM.created_at:type_name -> google.protobuf.Timestamp + 18, // 5: rookery.v1.VM.updated_at:type_name -> google.protobuf.Timestamp 0, // 6: rookery.v1.ListVMsResponse.vms:type_name -> rookery.v1.VM - 16, // 7: rookery.v1.VMMetrics.sampled_at:type_name -> google.protobuf.Timestamp - 1, // 8: rookery.v1.VMService.ListVMs:input_type -> rookery.v1.ListVMsRequest - 3, // 9: rookery.v1.VMService.GetVM:input_type -> rookery.v1.GetVMRequest - 4, // 10: rookery.v1.VMService.CreateVM:input_type -> rookery.v1.CreateVMRequest - 5, // 11: rookery.v1.VMService.UpdateVM:input_type -> rookery.v1.UpdateVMRequest - 6, // 12: rookery.v1.VMService.DeleteVM:input_type -> rookery.v1.DeleteVMRequest - 7, // 13: rookery.v1.VMService.StartVM:input_type -> rookery.v1.StartVMRequest - 8, // 14: rookery.v1.VMService.StopVM:input_type -> rookery.v1.StopVMRequest - 9, // 15: rookery.v1.VMService.ResetVM:input_type -> rookery.v1.ResetVMRequest - 10, // 16: rookery.v1.VMService.CtrlAltDel:input_type -> rookery.v1.CtrlAltDelRequest - 11, // 17: rookery.v1.VMService.GetVMMetrics:input_type -> rookery.v1.GetVMMetricsRequest - 2, // 18: rookery.v1.VMService.ListVMs:output_type -> rookery.v1.ListVMsResponse - 0, // 19: rookery.v1.VMService.GetVM:output_type -> rookery.v1.VM - 0, // 20: rookery.v1.VMService.CreateVM:output_type -> rookery.v1.VM - 0, // 21: rookery.v1.VMService.UpdateVM:output_type -> rookery.v1.VM - 17, // 22: rookery.v1.VMService.DeleteVM:output_type -> google.protobuf.Empty - 0, // 23: rookery.v1.VMService.StartVM:output_type -> rookery.v1.VM - 0, // 24: rookery.v1.VMService.StopVM:output_type -> rookery.v1.VM - 0, // 25: rookery.v1.VMService.ResetVM:output_type -> rookery.v1.VM - 0, // 26: rookery.v1.VMService.CtrlAltDel:output_type -> rookery.v1.VM - 12, // 27: rookery.v1.VMService.GetVMMetrics:output_type -> rookery.v1.VMMetrics - 18, // [18:28] is the sub-list for method output_type - 8, // [8:18] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 18, // 7: rookery.v1.VMMetrics.sampled_at:type_name -> google.protobuf.Timestamp + 14, // 8: rookery.v1.VMMetrics.block_devices:type_name -> rookery.v1.BlockDeviceMetrics + 1, // 9: rookery.v1.VMService.ListVMs:input_type -> rookery.v1.ListVMsRequest + 3, // 10: rookery.v1.VMService.GetVM:input_type -> rookery.v1.GetVMRequest + 4, // 11: rookery.v1.VMService.CreateVM:input_type -> rookery.v1.CreateVMRequest + 5, // 12: rookery.v1.VMService.UpdateVM:input_type -> rookery.v1.UpdateVMRequest + 6, // 13: rookery.v1.VMService.DeleteVM:input_type -> rookery.v1.DeleteVMRequest + 7, // 14: rookery.v1.VMService.StartVM:input_type -> rookery.v1.StartVMRequest + 9, // 15: rookery.v1.VMService.StopVM:input_type -> rookery.v1.StopVMRequest + 10, // 16: rookery.v1.VMService.ResetVM:input_type -> rookery.v1.ResetVMRequest + 11, // 17: rookery.v1.VMService.CtrlAltDel:input_type -> rookery.v1.CtrlAltDelRequest + 8, // 18: rookery.v1.VMService.ResumeVM:input_type -> rookery.v1.ResumeVMRequest + 12, // 19: rookery.v1.VMService.GetVMMetrics:input_type -> rookery.v1.GetVMMetricsRequest + 2, // 20: rookery.v1.VMService.ListVMs:output_type -> rookery.v1.ListVMsResponse + 0, // 21: rookery.v1.VMService.GetVM:output_type -> rookery.v1.VM + 0, // 22: rookery.v1.VMService.CreateVM:output_type -> rookery.v1.VM + 0, // 23: rookery.v1.VMService.UpdateVM:output_type -> rookery.v1.VM + 19, // 24: rookery.v1.VMService.DeleteVM:output_type -> google.protobuf.Empty + 0, // 25: rookery.v1.VMService.StartVM:output_type -> rookery.v1.VM + 0, // 26: rookery.v1.VMService.StopVM:output_type -> rookery.v1.VM + 0, // 27: rookery.v1.VMService.ResetVM:output_type -> rookery.v1.VM + 0, // 28: rookery.v1.VMService.CtrlAltDel:output_type -> rookery.v1.VM + 0, // 29: rookery.v1.VMService.ResumeVM:output_type -> rookery.v1.VM + 13, // 30: rookery.v1.VMService.GetVMMetrics:output_type -> rookery.v1.VMMetrics + 20, // [20:31] is the sub-list for method output_type + 9, // [9:20] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_rookery_v1_vm_proto_init() } @@ -1251,7 +1451,7 @@ func file_rookery_v1_vm_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_rookery_v1_vm_proto_rawDesc), len(file_rookery_v1_vm_proto_rawDesc)), NumEnums: 0, - NumMessages: 13, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/design/gen/rookery/v1/vm.pb.gw.go b/design/gen/rookery/v1/vm.pb.gw.go index 311b40a..0f449ae 100644 --- a/design/gen/rookery/v1/vm.pb.gw.go +++ b/design/gen/rookery/v1/vm.pb.gw.go @@ -362,6 +362,45 @@ func local_request_VMService_CtrlAltDel_0(ctx context.Context, marshaler runtime return msg, metadata, err } +func request_VMService_ResumeVM_0(ctx context.Context, marshaler runtime.Marshaler, client VMServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ResumeVMRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.ResumeVM(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_VMService_ResumeVM_0(ctx context.Context, marshaler runtime.Marshaler, server VMServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ResumeVMRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.ResumeVM(ctx, &protoReq) + return msg, metadata, err +} + func request_VMService_GetVMMetrics_0(ctx context.Context, marshaler runtime.Marshaler, client VMServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq GetVMMetricsRequest @@ -587,6 +626,26 @@ func RegisterVMServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, } forward_VMService_CtrlAltDel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_VMService_ResumeVM_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/rookery.v1.VMService/ResumeVM", runtime.WithHTTPPathPattern("/api/vms/{id}/resume")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_VMService_ResumeVM_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_VMService_ResumeVM_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodGet, pattern_VMService_GetVMMetrics_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -800,6 +859,23 @@ func RegisterVMServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, } forward_VMService_CtrlAltDel_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_VMService_ResumeVM_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/rookery.v1.VMService/ResumeVM", runtime.WithHTTPPathPattern("/api/vms/{id}/resume")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_VMService_ResumeVM_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_VMService_ResumeVM_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodGet, pattern_VMService_GetVMMetrics_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -839,6 +915,7 @@ var ( pattern_VMService_StopVM_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "vms", "id", "stop"}, "")) pattern_VMService_ResetVM_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "vms", "id", "reset"}, "")) pattern_VMService_CtrlAltDel_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "vms", "id", "ctrl-alt-del"}, "")) + pattern_VMService_ResumeVM_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "vms", "id", "resume"}, "")) pattern_VMService_GetVMMetrics_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "vms", "id", "metrics"}, "")) ) @@ -852,5 +929,6 @@ var ( forward_VMService_StopVM_0 = runtime.ForwardResponseMessage forward_VMService_ResetVM_0 = runtime.ForwardResponseMessage forward_VMService_CtrlAltDel_0 = runtime.ForwardResponseMessage + forward_VMService_ResumeVM_0 = runtime.ForwardResponseMessage forward_VMService_GetVMMetrics_0 = runtime.ForwardResponseMessage ) diff --git a/design/gen/rookery/v1/vm.pb.validate.go b/design/gen/rookery/v1/vm.pb.validate.go index eb1e30e..ad8fd6f 100644 --- a/design/gen/rookery/v1/vm.pb.validate.go +++ b/design/gen/rookery/v1/vm.pb.validate.go @@ -1414,6 +1414,126 @@ var _ interface { ErrorName() string } = StartVMRequestValidationError{} +// Validate checks the field values on ResumeVMRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ResumeVMRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ResumeVMRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ResumeVMRequestMultiError, or nil if none found. +func (m *ResumeVMRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ResumeVMRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if err := m._validateUuid(m.GetId()); err != nil { + err = ResumeVMRequestValidationError{ + field: "Id", + reason: "value must be a valid UUID", + cause: err, + } + if !all { + return err + } + errors = append(errors, err) + } + + if len(errors) > 0 { + return ResumeVMRequestMultiError(errors) + } + + return nil +} + +func (m *ResumeVMRequest) _validateUuid(uuid string) error { + if matched := _vm_uuidPattern.MatchString(uuid); !matched { + return errors.New("invalid uuid format") + } + + return nil +} + +// ResumeVMRequestMultiError is an error wrapping multiple validation errors +// returned by ResumeVMRequest.ValidateAll() if the designated constraints +// aren't met. +type ResumeVMRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ResumeVMRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ResumeVMRequestMultiError) AllErrors() []error { return m } + +// ResumeVMRequestValidationError is the validation error returned by +// ResumeVMRequest.Validate if the designated constraints aren't met. +type ResumeVMRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ResumeVMRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ResumeVMRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ResumeVMRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ResumeVMRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ResumeVMRequestValidationError) ErrorName() string { return "ResumeVMRequestValidationError" } + +// Error satisfies the builtin error interface +func (e ResumeVMRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sResumeVMRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ResumeVMRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ResumeVMRequestValidationError{} + // Validate checks the field values on StopVMRequest with the rules defined in // the proto definition for this message. If any rules are violated, the first // error encountered is returned, or nil if there are no violations. @@ -1981,6 +2101,40 @@ func (m *VMMetrics) validate(all bool) error { } } + for idx, item := range m.GetBlockDevices() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, VMMetricsValidationError{ + field: fmt.Sprintf("BlockDevices[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, VMMetricsValidationError{ + field: fmt.Sprintf("BlockDevices[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return VMMetricsValidationError{ + field: fmt.Sprintf("BlockDevices[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + if len(errors) > 0 { return VMMetricsMultiError(errors) } @@ -2057,3 +2211,127 @@ var _ interface { Cause() error ErrorName() string } = VMMetricsValidationError{} + +// Validate checks the field values on BlockDeviceMetrics with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *BlockDeviceMetrics) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on BlockDeviceMetrics with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// BlockDeviceMetricsMultiError, or nil if none found. +func (m *BlockDeviceMetrics) ValidateAll() error { + return m.validate(true) +} + +func (m *BlockDeviceMetrics) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for DiskId + + // no validation rules for Name + + // no validation rules for Device + + // no validation rules for ReadBytes + + // no validation rules for WriteBytes + + // no validation rules for ReadOperations + + // no validation rules for WriteOperations + + // no validation rules for FailedReadOperations + + // no validation rules for FailedWriteOperations + + // no validation rules for InvalidReadOperations + + // no validation rules for InvalidWriteOperations + + if len(errors) > 0 { + return BlockDeviceMetricsMultiError(errors) + } + + return nil +} + +// BlockDeviceMetricsMultiError is an error wrapping multiple validation errors +// returned by BlockDeviceMetrics.ValidateAll() if the designated constraints +// aren't met. +type BlockDeviceMetricsMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m BlockDeviceMetricsMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m BlockDeviceMetricsMultiError) AllErrors() []error { return m } + +// BlockDeviceMetricsValidationError is the validation error returned by +// BlockDeviceMetrics.Validate if the designated constraints aren't met. +type BlockDeviceMetricsValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e BlockDeviceMetricsValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e BlockDeviceMetricsValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e BlockDeviceMetricsValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e BlockDeviceMetricsValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e BlockDeviceMetricsValidationError) ErrorName() string { + return "BlockDeviceMetricsValidationError" +} + +// Error satisfies the builtin error interface +func (e BlockDeviceMetricsValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sBlockDeviceMetrics.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = BlockDeviceMetricsValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = BlockDeviceMetricsValidationError{} diff --git a/design/gen/rookery/v1/vm_grpc.pb.go b/design/gen/rookery/v1/vm_grpc.pb.go index 7728f99..cc3e8b6 100644 --- a/design/gen/rookery/v1/vm_grpc.pb.go +++ b/design/gen/rookery/v1/vm_grpc.pb.go @@ -29,6 +29,7 @@ const ( VMService_StopVM_FullMethodName = "/rookery.v1.VMService/StopVM" VMService_ResetVM_FullMethodName = "/rookery.v1.VMService/ResetVM" VMService_CtrlAltDel_FullMethodName = "/rookery.v1.VMService/CtrlAltDel" + VMService_ResumeVM_FullMethodName = "/rookery.v1.VMService/ResumeVM" VMService_GetVMMetrics_FullMethodName = "/rookery.v1.VMService/GetVMMetrics" ) @@ -47,6 +48,7 @@ type VMServiceClient interface { StopVM(ctx context.Context, in *StopVMRequest, opts ...grpc.CallOption) (*VM, error) ResetVM(ctx context.Context, in *ResetVMRequest, opts ...grpc.CallOption) (*VM, error) CtrlAltDel(ctx context.Context, in *CtrlAltDelRequest, opts ...grpc.CallOption) (*VM, error) + ResumeVM(ctx context.Context, in *ResumeVMRequest, opts ...grpc.CallOption) (*VM, error) GetVMMetrics(ctx context.Context, in *GetVMMetricsRequest, opts ...grpc.CallOption) (*VMMetrics, error) } @@ -148,6 +150,16 @@ func (c *vMServiceClient) CtrlAltDel(ctx context.Context, in *CtrlAltDelRequest, return out, nil } +func (c *vMServiceClient) ResumeVM(ctx context.Context, in *ResumeVMRequest, opts ...grpc.CallOption) (*VM, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VM) + err := c.cc.Invoke(ctx, VMService_ResumeVM_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *vMServiceClient) GetVMMetrics(ctx context.Context, in *GetVMMetricsRequest, opts ...grpc.CallOption) (*VMMetrics, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VMMetrics) @@ -173,6 +185,7 @@ type VMServiceServer interface { StopVM(context.Context, *StopVMRequest) (*VM, error) ResetVM(context.Context, *ResetVMRequest) (*VM, error) CtrlAltDel(context.Context, *CtrlAltDelRequest) (*VM, error) + ResumeVM(context.Context, *ResumeVMRequest) (*VM, error) GetVMMetrics(context.Context, *GetVMMetricsRequest) (*VMMetrics, error) mustEmbedUnimplementedVMServiceServer() } @@ -211,6 +224,9 @@ func (UnimplementedVMServiceServer) ResetVM(context.Context, *ResetVMRequest) (* func (UnimplementedVMServiceServer) CtrlAltDel(context.Context, *CtrlAltDelRequest) (*VM, error) { return nil, status.Error(codes.Unimplemented, "method CtrlAltDel not implemented") } +func (UnimplementedVMServiceServer) ResumeVM(context.Context, *ResumeVMRequest) (*VM, error) { + return nil, status.Error(codes.Unimplemented, "method ResumeVM not implemented") +} func (UnimplementedVMServiceServer) GetVMMetrics(context.Context, *GetVMMetricsRequest) (*VMMetrics, error) { return nil, status.Error(codes.Unimplemented, "method GetVMMetrics not implemented") } @@ -397,6 +413,24 @@ func _VMService_CtrlAltDel_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _VMService_ResumeVM_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ResumeVMRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VMServiceServer).ResumeVM(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VMService_ResumeVM_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VMServiceServer).ResumeVM(ctx, req.(*ResumeVMRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _VMService_GetVMMetrics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetVMMetricsRequest) if err := dec(in); err != nil { @@ -458,6 +492,10 @@ var VMService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CtrlAltDel", Handler: _VMService_CtrlAltDel_Handler, }, + { + MethodName: "ResumeVM", + Handler: _VMService_ResumeVM_Handler, + }, { MethodName: "GetVMMetrics", Handler: _VMService_GetVMMetrics_Handler, diff --git a/design/openapi/rookery.swagger.json b/design/openapi/rookery.swagger.json index 061783a..dcaeb70 100644 --- a/design/openapi/rookery.swagger.json +++ b/design/openapi/rookery.swagger.json @@ -2405,6 +2405,38 @@ ] } }, + "/api/storage-backends/kouja/probe": { + "post": { + "operationId": "StorageBackendService_ProbeKoujaBackend", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1ProbeKoujaBackendResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1ProbeKoujaBackendRequest" + } + } + ], + "tags": [ + "StorageBackendService" + ] + } + }, "/api/storage-backends/{id}": { "get": { "operationId": "StorageBackendService_GetStorageBackend", @@ -3195,6 +3227,36 @@ ] } }, + "/api/vms/{id}/resume": { + "post": { + "operationId": "VMService_ResumeVM", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1VM" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "VMService" + ] + } + }, "/api/vms/{id}/start": { "post": { "operationId": "VMService_StartVM", @@ -3824,6 +3886,14 @@ }, "provisioning_timeout": { "type": "string" + }, + "read_bps": { + "type": "string", + "format": "int64" + }, + "write_bps": { + "type": "string", + "format": "int64" } } }, @@ -4452,6 +4522,53 @@ } } }, + "v1BlockDeviceMetrics": { + "type": "object", + "properties": { + "disk_id": { + "type": "string", + "format": "int64" + }, + "name": { + "type": "string" + }, + "device": { + "type": "string" + }, + "read_bytes": { + "type": "string", + "format": "uint64" + }, + "write_bytes": { + "type": "string", + "format": "uint64" + }, + "read_operations": { + "type": "string", + "format": "uint64" + }, + "write_operations": { + "type": "string", + "format": "uint64" + }, + "failed_read_operations": { + "type": "string", + "format": "uint64" + }, + "failed_write_operations": { + "type": "string", + "format": "uint64" + }, + "invalid_read_operations": { + "type": "string", + "format": "uint64" + }, + "invalid_write_operations": { + "type": "string", + "format": "uint64" + } + } + }, "v1CgroupClass": { "type": "object", "properties": { @@ -4730,6 +4847,14 @@ "host_device": { "type": "string", "description": "host_device registers an existing host block device (a whole disk,\npartition, or logical volume) for passthrough to one VM. When set,\nstorage_backend_id, size_gb, is_template, template_disk_id, and\nprovisioning settings must be omitted; the size is read from the device." + }, + "read_bps": { + "type": "string", + "format": "int64" + }, + "write_bps": { + "type": "string", + "format": "int64" } } }, @@ -5170,6 +5295,15 @@ "protocol": { "type": "string", "description": "protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments." + }, + "read_bps": { + "type": "string", + "format": "int64", + "description": "read_bps and write_bps are QEMU block throttling limits in bytes per\nsecond. Zero means unlimited." + }, + "write_bps": { + "type": "string", + "format": "int64" } } }, @@ -5548,6 +5682,16 @@ }, "host_nqn": { "type": "string" + }, + "ca_pem": { + "type": "string", + "description": "PEM content is preferred when set. ca_file/client_cert_file/client_key_file\nremain for existing backends and CLI/Terraform configurations." + }, + "client_cert_pem": { + "type": "string" + }, + "client_key_pem": { + "type": "string" } }, "description": "KoujaConfig configures a kouja storage backend. Protocol defaults to iscsi." @@ -6330,6 +6474,43 @@ } } }, + "v1ProbeKoujaBackendRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/v1KoujaConfig" + } + } + }, + "v1ProbeKoujaBackendResponse": { + "type": "object", + "properties": { + "reachable": { + "type": "boolean" + }, + "ready": { + "type": "boolean" + }, + "detail": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "protocols": { + "type": "array", + "items": { + "type": "string" + } + }, + "storage_classes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "v1Route": { "type": "object", "properties": { @@ -6948,6 +7129,15 @@ "protocol": { "type": "string", "description": "protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments." + }, + "read_bps": { + "type": "string", + "format": "int64", + "description": "read_bps and write_bps are QEMU block throttling limits in bytes per\nsecond. Zero means unlimited." + }, + "write_bps": { + "type": "string", + "format": "int64" } } }, @@ -7072,6 +7262,13 @@ "sampled_at": { "type": "string", "format": "date-time" + }, + "block_devices": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1BlockDeviceMetrics" + } } } }, diff --git a/design/proto/rookery/v1/disk.proto b/design/proto/rookery/v1/disk.proto index 289dd55..265a640 100644 --- a/design/proto/rookery/v1/disk.proto +++ b/design/proto/rookery/v1/disk.proto @@ -58,6 +58,10 @@ message VMDisk { string locator = 11; // protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments. string protocol = 12; + // read_bps and write_bps are QEMU block throttling limits in bytes per + // second. Zero means unlimited. + int64 read_bps = 13; + int64 write_bps = 14; } message Disk { @@ -90,6 +94,10 @@ message Disk { string deletion_error = 22; // protocol distinguishes local, iscsi, nvme-tcp, and rbd attachments. string protocol = 23; + // read_bps and write_bps are QEMU block throttling limits in bytes per + // second. Zero means unlimited. + int64 read_bps = 24; + int64 write_bps = 25; } message ImportableDisk { @@ -130,6 +138,8 @@ message CreateDiskRequest { // storage_backend_id, size_gb, is_template, template_disk_id, and // provisioning settings must be omitted; the size is read from the device. string host_device = 9 [(validate.rules).string = {ignore_empty: true, max_len: 4096}]; + optional int64 read_bps = 10 [(validate.rules).int64 = {gte: 0}]; + optional int64 write_bps = 11 [(validate.rules).int64 = {gte: 0}]; } message DeleteDiskResponse { @@ -142,6 +152,8 @@ message UpdateDiskRequest { optional bool is_template = 2; optional string provisioning_script = 3 [(validate.rules).string = {max_len: 4096}]; optional string provisioning_timeout = 4 [(validate.rules).string = {max_len: 32}]; + optional int64 read_bps = 5 [(validate.rules).int64 = {gte: 0}]; + optional int64 write_bps = 6 [(validate.rules).int64 = {gte: 0}]; } message DeleteDiskRequest { diff --git a/design/proto/rookery/v1/storage_backend.proto b/design/proto/rookery/v1/storage_backend.proto index 5543123..6a5b717 100644 --- a/design/proto/rookery/v1/storage_backend.proto +++ b/design/proto/rookery/v1/storage_backend.proto @@ -66,6 +66,13 @@ service StorageBackendService { body: "*" }; } + + rpc ProbeKoujaBackend(ProbeKoujaBackendRequest) returns (ProbeKoujaBackendResponse) { + option (google.api.http) = { + post: "/api/storage-backends/kouja/probe" + body: "*" + }; + } } message StorageBackendConfig { @@ -93,6 +100,11 @@ message KoujaConfig { string server_name = 8 [(validate.rules).string = {max_len: 255}]; string portal = 9 [(validate.rules).string = {max_len: 255}]; string host_nqn = 10 [(validate.rules).string = {max_len: 223}]; + // PEM content is preferred when set. ca_file/client_cert_file/client_key_file + // remain for existing backends and CLI/Terraform configurations. + string ca_pem = 11 [(validate.rules).string = {max_len: 65536}]; + string client_cert_pem = 12 [(validate.rules).string = {max_len: 65536}]; + string client_key_pem = 13 [(validate.rules).string = {max_len: 65536}]; } message StorageBackend { @@ -195,3 +207,16 @@ message StorageClassCapacity { int64 allocatable_bytes = 6; int64 max_volume_bytes = 7; } + +message ProbeKoujaBackendRequest { + KoujaConfig config = 1 [(validate.rules).message = {required: true}]; +} + +message ProbeKoujaBackendResponse { + bool reachable = 1; + bool ready = 2; + string detail = 3; + string owner = 4; + repeated string protocols = 5; + repeated string storage_classes = 6; +} diff --git a/design/proto/rookery/v1/vm.proto b/design/proto/rookery/v1/vm.proto index 000e4c1..c44a04d 100644 --- a/design/proto/rookery/v1/vm.proto +++ b/design/proto/rookery/v1/vm.proto @@ -59,6 +59,10 @@ service VMService { option (google.api.http) = {post: "/api/vms/{id}/ctrl-alt-del"}; } + rpc ResumeVM(ResumeVMRequest) returns (VM) { + option (google.api.http) = {post: "/api/vms/{id}/resume"}; + } + rpc GetVMMetrics(GetVMMetricsRequest) returns (VMMetrics) { option (google.api.http) = {get: "/api/vms/{id}/metrics"}; } @@ -144,6 +148,10 @@ message StartVMRequest { string id = 1 [(validate.rules).string.uuid = true]; } +message ResumeVMRequest { + string id = 1 [(validate.rules).string.uuid = true]; +} + message StopVMRequest { string id = 1 [(validate.rules).string.uuid = true]; } @@ -178,4 +186,19 @@ message VMMetrics { uint64 network_rx_packets_total = 15; uint64 network_tx_packets_total = 16; google.protobuf.Timestamp sampled_at = 17; + repeated BlockDeviceMetrics block_devices = 18; +} + +message BlockDeviceMetrics { + int64 disk_id = 1; + string name = 2; + string device = 3; + uint64 read_bytes = 4; + uint64 write_bytes = 5; + uint64 read_operations = 6; + uint64 write_operations = 7; + uint64 failed_read_operations = 8; + uint64 failed_write_operations = 9; + uint64 invalid_read_operations = 10; + uint64 invalid_write_operations = 11; } diff --git a/docs/api.md b/docs/api.md index bb4544c..98d09cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -169,17 +169,18 @@ labels in sync through the same API. | `PUT` | `/api/cgroup-settings` | Apply host-wide cgroup v2 limits and classes | | `GET` | `/api/cgroup-usage` | Live memory, CPU, task, and I/O usage per cgroup node | | `GET` | `/api/storage-backends` | List registered storage backends | -| `POST` | `/api/storage-backends` | Register an LVM thin-pool backend | +| `POST` | `/api/storage-backends` | Register an LVM thin-pool or kouja backend | | `GET` | `/api/storage-backends/{id}` | Get one storage backend | | `PUT` | `/api/storage-backends/{id}` | Update an unused backend | | `DELETE` | `/api/storage-backends/{id}` | Delete a backend with no disks | | `GET` | `/api/storage-backends/{id}/importable-disks` | List existing thin volumes and registration state | | `POST` | `/api/storage-backends/{id}/import-disks` | Register existing thin volumes as disks | +| `POST` | `/api/storage-backends/kouja/probe` | Validate unsaved kouja connection fields and list storage classes | | `GET` | `/api/disks` | List all disks | | `POST` | `/api/disks` | Create a thin-LVM disk, host passthrough disk, template, or provisioned template clone | | `GET` | `/api/disks/{id}` | Get one disk | -| `PUT` | `/api/disks/{id}` | Update template status or template provisioning settings | -| `DELETE` | `/api/disks/{id}` | Delete an unattached disk | +| `PUT` | `/api/disks/{id}` | Update template/provisioning settings or per-disk `read_bps`/`write_bps` limits | +| `DELETE` | `/api/disks/{id}` | Delete an unattached disk; remote kouja deletes return `202` and finish asynchronously | | `GET` | `/api/media` | List installation media | | `POST` | `/api/media` | Register an existing ISO file | | `GET` | `/api/media/{id}` | Get one media entry | @@ -307,11 +308,12 @@ VPP command. | `GET` | `/api/vms` | List all VMs | | `POST` | `/api/vms` | Create a VM with an existing disk and start it | | `GET` | `/api/vms/{id}` | Get one VM | -| `GET` | `/api/vms/{id}/metrics` | QEMU process CPU/memory/I/O and VPP vhost RX/TX telemetry | +| `GET` | `/api/vms/{id}/metrics` | QEMU process CPU/memory/I/O, per-drive blockstats, and VPP vhost RX/TX telemetry | | `PUT` | `/api/vms/{id}` | Update VM settings | | `DELETE` | `/api/vms/{id}` | Delete a VM, freeing its disks | | `POST` | `/api/vms/{id}/start` | Start the VM with QEMU/KVM | | `POST` | `/api/vms/{id}/stop` | Stop the VM | +| `POST` | `/api/vms/{id}/resume` | Resume a QEMU VM paused by an I/O error | | `POST` | `/api/vms/{id}/ctrl-alt-del` | Send Ctrl+Alt+Del via QEMU's monitor | | `POST` | `/api/vms/{id}/reset` | Force a VM reset; power-cycles legacy VMs without a monitor | | `GET` | `/ws/vms/{id}/vnc` | WebSocket proxy to the VM's VNC Unix socket | @@ -332,8 +334,29 @@ curl -sS -X POST http://127.0.0.1:8080/api/disks \ ``` `POST /api/disks` accepts an optional `storage_backend_id`; omit it for the -default backend. Empty disks run `lvcreate` against the selected backend's thin -pool. A backend with `supports_templates` can also create a disk from another +default backend. LVM disks run `lvcreate` against the selected backend's thin +pool. kouja disks are created and exported by the kouja agent; their response +includes `source=kouja`, `protocol=iscsi`, a display-only `locator`, and empty +`device`. Delete a kouja disk with `DELETE /api/disks/{id}`; the API returns +`202 Accepted` and the reclaimer unexports and deletes the volume. A +`delete_failed` disk can be retried with the same DELETE call. + +Disks accept optional `read_bps` and `write_bps` limits in bytes per second on +create and update (`0` means unlimited). Rookery passes them to QEMU as +`throttling.bps-read` and `throttling.bps-write`; limits take effect the next +time the VM starts. The Disks tab shows current limits and provides a **Limits** +action. + +VM responses include `run_state` for running VMs. QEMU uses +`rerror=stop,werror=stop` for remote iSCSI drives, so a transport error pauses +the VM as `paused (io-error)` instead of silently dropping I/O. Use +`POST /api/vms/{id}/resume` (or the UI **Resume** action) after the storage path +recovers. + +A `kouja` backend's `config.kouja` accepts `ca_pem`, `client_cert_pem`, and +`client_key_pem` as PEM content; the older `ca_file`, `client_cert_file`, and +`client_key_file` paths are still accepted when the PEM fields are empty. The +UI stores the content with the backend. A backend with `supports_templates` can also create a disk from another disk marked as a template using `template_disk_id`; the backend's `preparation_script` creates the clone LV/device. The clone size defaults to the template size and may only be increased. diff --git a/docs/architecture.md b/docs/architecture.md index eecfa62..fed44d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture Rookery is a single-host management plane for KVM virtual machines backed by -LVM thin storage and FD.io VPP networking. It is split into two long-running Go +LVM thin, kouja, and host-device storage plus FD.io VPP networking. It is split into two long-running Go processes: - `rookery` owns the API, SQLite, QEMU lifecycle, LVM, and VPP network diff --git a/docs/designs/kouja-storage-backend.md b/docs/designs/kouja-storage-backend.md index ac9dd16..97d14e2 100644 --- a/docs/designs/kouja-storage-backend.md +++ b/docs/designs/kouja-storage-backend.md @@ -883,8 +883,11 @@ kouja RPC. The lock order is `resourceMu` (disk mutex) → `s.mu` (VM run mutex) `TargetPresent=false` or `ACLPresent=false`, fail before starting. If the observation is older, proceed and let QEMU's connection error be authoritative; do not assume a fixed 10-second reconcile window; - * reject `SessionState=ACTIVE`. Allow `NONE`; allow `UNKNOWN` or - `UNSPECIFIED` only when `ActiveSessions=0`, and log it; + * treat `Observation.ActiveSessions` as authoritative only when + `ObservedAt` is at most ten seconds old. Reject a fresh non-zero active + count and retry briefly; a stale `SessionState=ACTIVE` must not block a + start after QEMU has logged out. The target still refuses a second login + if a real session remains; * call `GetCredential` with no owner and derive the attachment from the live export; * compare the live export's raw `export_portal` plus diff --git a/docs/operations.md b/docs/operations.md index 84f2530..d2804dd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -186,7 +186,22 @@ stopped. The `default` backend is bootstrapped from the API `-vg`/`-thin-pool` flags when supplied; otherwise `serve` starts with the first registered storage backend, and adding a backend in the UI updates -readiness. LVM thin-pool backends can be registered and selected from the UI. +readiness. LVM thin-pool backends can be registered and selected from the UI. Kouja +backends are registered with the driver `kouja`, an endpoint, storage class, +iSCSI initiator IQN, and PEM-encoded CA, client certificate, and client key +content. The UI accepts pasted PEM or files loaded in the browser and stores the +content with the backend; protect the SQLite database and backups accordingly. +Legacy `ca_file`/`client_cert_file`/`client_key_file` paths remain accepted for +existing backends. Kouja's session observation is refreshed on its reconcile interval, so a stale +`SESSION_ACTIVE` is ignored; only a fresh non-zero active-session count blocks a +start. If that happens, Rookery waits briefly and rechecks, and the UI retries +the start automatically after the previous QEMU session drains. The client certificate must contain exactly one +`spiffe://kouja/caller/` URI SAN; keep that identity stable across +certificate rotation. Kouja disks are exported directly to QEMU; +the host does not create an `iscsiadm` session. Queue a kouja disk delete and +let the reclaimer finish it; `delete_failed` rows can be retried. Per-disk +read/write bandwidth limits are set in the Disks UI and applied by QEMU at the +next VM start. Each configured LVM backend has an **Import** action that lists existing thin volumes in its pool and registers selected volumes as disks without modifying LVM. Already registered volumes are shown as unavailable. The driver and config @@ -301,7 +316,8 @@ restored, and failures are logged without stopping the API. The **Monitor** action opens a live telemetry dialog that samples the QEMU process every two seconds and shows CPU usage, RSS/virtual memory, disk read -and write rates, network RX/TX rates, uptime, and PID. It also keeps 60 samples +and write rates, per-block-device read/write totals and failed-operation +counters, network RX/TX rates, uptime, and PID. It also keeps 60 samples (about two minutes) and draws CPU, memory RSS, disk I/O, and network I/O history on embedded canvas charts. Network counters are read from the VM's VPP vhost interfaces. It reports process- and vhost-level metrics, not guest OS metrics. diff --git a/internal/gateway/disk_kouja.go b/internal/gateway/disk_kouja.go index ef9d8cd..87bee31 100644 --- a/internal/gateway/disk_kouja.go +++ b/internal/gateway/disk_kouja.go @@ -41,8 +41,9 @@ func (s *diskService) createKoujaDisk(ctx context.Context, request *rookeryv1.Cr } storageClass = backend.Config.Kouja.StorageClass } + readBPS, writeBPS := requestDiskBandwidth(request) disk := store.Disk{ - Name: name, SizeGB: sizeGB, + Name: name, SizeGB: sizeGB, ReadBPS: readBPS, WriteBPS: writeBPS, StorageBackendID: &backend.ID, StorageBackendName: backend.Name, Source: store.DiskSourceKouja, Protocol: string(protocol), BackendMetadata: store.DiskBackendMetadata{ diff --git a/internal/gateway/disk_kouja_test.go b/internal/gateway/disk_kouja_test.go index 3a6c366..56d616d 100644 --- a/internal/gateway/disk_kouja_test.go +++ b/internal/gateway/disk_kouja_test.go @@ -19,8 +19,12 @@ import ( "testing" "time" + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" + "google.golang.org/grpc" + "github.com/voidstarit/rookery/internal/blockdev" "github.com/voidstarit/rookery/internal/gateway" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/storage" "github.com/voidstarit/rookery/internal/store" ) @@ -207,13 +211,34 @@ func gatewayCert(t *testing.T) (string, string, string) { return certFile, keyFile, caFile } -func koujaBackendBody(t *testing.T, name string) string { +func koujaBackendPEMBody(t *testing.T, name string) string { t.Helper() certFile, keyFile, caFile := gatewayCert(t) - return `{"name":"` + name + `","driver":"kouja","config":{"kouja":{` + - `"endpoint":"10.0.0.1:9443","protocol":"iscsi","storage_class":"nvme",` + - `"initiator":"iqn.2026-11.io.example:rookery","ca_file":"` + caFile + `",` + - `"client_cert_file":"` + certFile + `","client_key_file":"` + keyFile + `"}}}` + certPEM, err := os.ReadFile(certFile) + if err != nil { + t.Fatal(err) + } + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + t.Fatal(err) + } + payload := map[string]any{ + "name": name, "driver": "kouja", + "config": map[string]any{"kouja": map[string]any{ + "endpoint": "10.0.0.1:9443", "protocol": "iscsi", "storage_class": "nvme", + "initiator": "iqn.2026-11.io.example:rookery", + "ca_pem": string(caPEM), "client_cert_pem": string(certPEM), "client_key_pem": string(keyPEM), + }}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return string(data) } func TestKoujaBackendConfigAndHealthRoutes(t *testing.T) { @@ -233,7 +258,7 @@ func TestKoujaBackendConfigAndHealthRoutes(t *testing.T) { server := httptest.NewServer(gw.Handler) t.Cleanup(server.Close) - response, err := http.Post(server.URL+"/api/storage-backends", "application/json", strings.NewReader(koujaBackendBody(t, "kouja-backend"))) + response, err := http.Post(server.URL+"/api/storage-backends", "application/json", strings.NewReader(koujaBackendPEMBody(t, "kouja-backend"))) if err != nil { t.Fatal(err) } @@ -339,3 +364,175 @@ func TestKoujaOnlyBackendSelection(t *testing.T) { } _ = response.Body.Close() } + +type probeKoujaAPI struct{} + +func (probeKoujaAPI) CreateVolume(context.Context, *koujav1.CreateVolumeRequest, ...grpc.CallOption) (*koujav1.CreateVolumeResponse, error) { + return nil, nil +} +func (probeKoujaAPI) GetVolume(context.Context, *koujav1.GetVolumeRequest, ...grpc.CallOption) (*koujav1.GetVolumeResponse, error) { + return nil, nil +} +func (probeKoujaAPI) GetCredential(context.Context, *koujav1.GetCredentialRequest, ...grpc.CallOption) (*koujav1.GetCredentialResponse, error) { + return nil, nil +} +func (probeKoujaAPI) UnexportVolume(context.Context, *koujav1.UnexportVolumeRequest, ...grpc.CallOption) (*koujav1.UnexportVolumeResponse, error) { + return nil, nil +} +func (probeKoujaAPI) DeleteVolume(context.Context, *koujav1.DeleteVolumeRequest, ...grpc.CallOption) (*koujav1.DeleteVolumeResponse, error) { + return nil, nil +} +func (probeKoujaAPI) GetInfo(context.Context, *koujav1.GetInfoRequest, ...grpc.CallOption) (*koujav1.GetInfoResponse, error) { + return &koujav1.GetInfoResponse{Ready: true, Protocols: []koujav1.Protocol{ + koujav1.Protocol_PROTOCOL_ISCSI, koujav1.Protocol_PROTOCOL_UNSPECIFIED, + }}, nil +} +func (probeKoujaAPI) ListStorageClasses(context.Context, *koujav1.ListStorageClassesRequest, ...grpc.CallOption) (*koujav1.ListStorageClassesResponse, error) { + return &koujav1.ListStorageClassesResponse{Classes: []*koujav1.StorageClass{{Name: "nvme"}, {Name: "archive"}}}, nil +} + +func koujaProbePEMBody(t *testing.T) string { + t.Helper() + certFile, keyFile, caFile := gatewayCert(t) + certPEM, err := os.ReadFile(certFile) + if err != nil { + t.Fatal(err) + } + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + t.Fatal(err) + } + payload := map[string]any{"config": map[string]any{ + "endpoint": "10.0.0.1:9443", "protocol": "iscsi", + "initiator": "iqn.2026-11.io.example:rookery", + "ca_pem": string(caPEM), "client_cert_pem": string(certPEM), "client_key_pem": string(keyPEM), + }} + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestKoujaProbeRoute(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "kouja-probe.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + gw, err := gateway.New(st, fakeReadiness{status: readyStatus()}, gateway.WithKoujaClientFactory(func(kouja.Config) (*kouja.Client, error) { + return kouja.NewClientFromService(probeKoujaAPI{}), nil + })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(gw.Close) + server := httptest.NewServer(gw.Handler) + t.Cleanup(server.Close) + response, err := http.Post(server.URL+"/api/storage-backends/kouja/probe", "application/json", strings.NewReader(koujaProbePEMBody(t))) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("probe status = %d, body = %s", response.StatusCode, readBody(t, response)) + } + var body map[string]any + decodeBody(t, response, &body) + classes, _ := body["storage_classes"].([]any) + if body["reachable"] != true || len(classes) != 2 || body["owner"] != "spiffe://kouja/caller/rookery" { + t.Fatalf("probe body = %+v", body) + } + // Invalid connection fields are rejected before a client is constructed. + bad, err := http.Post(server.URL+"/api/storage-backends/kouja/probe", "application/json", strings.NewReader(`{"config":{"endpoint":"10.0.0.1:9443"}}`)) + if err != nil { + t.Fatal(err) + } + if bad.StatusCode != http.StatusBadRequest { + t.Fatalf("bad probe status = %d, body = %s", bad.StatusCode, readBody(t, bad)) + } + _ = bad.Body.Close() +} + +type probeKoujaErrorAPI struct { + probeKoujaAPI + infoErr error + classesErr error +} + +func (a probeKoujaErrorAPI) GetInfo(context.Context, *koujav1.GetInfoRequest, ...grpc.CallOption) (*koujav1.GetInfoResponse, error) { + if a.infoErr != nil { + return nil, a.infoErr + } + return a.probeKoujaAPI.GetInfo(context.Background(), nil) +} + +func (a probeKoujaErrorAPI) ListStorageClasses(context.Context, *koujav1.ListStorageClassesRequest, ...grpc.CallOption) (*koujav1.ListStorageClassesResponse, error) { + if a.classesErr != nil { + return nil, a.classesErr + } + return a.probeKoujaAPI.ListStorageClasses(context.Background(), nil) +} + +type probeKoujaNotReadyAPI struct{ probeKoujaAPI } + +func (a probeKoujaNotReadyAPI) GetInfo(context.Context, *koujav1.GetInfoRequest, ...grpc.CallOption) (*koujav1.GetInfoResponse, error) { + return &koujav1.GetInfoResponse{Ready: false, Protocols: []koujav1.Protocol{koujav1.Protocol_PROTOCOL_ISCSI}}, nil +} + +func TestKoujaProbeErrors(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "kouja-probe-errors.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + tests := []struct { + name string + factory func(kouja.Config) (*kouja.Client, error) + status int + }{ + {name: "factory", factory: func(kouja.Config) (*kouja.Client, error) { return nil, errors.New("boom") }, status: http.StatusServiceUnavailable}, + {name: "info", factory: func(kouja.Config) (*kouja.Client, error) { + return kouja.NewClientFromService(probeKoujaErrorAPI{infoErr: errors.New("info failed")}), nil + }, status: http.StatusServiceUnavailable}, + {name: "classes", factory: func(kouja.Config) (*kouja.Client, error) { + return kouja.NewClientFromService(probeKoujaErrorAPI{classesErr: errors.New("classes failed")}), nil + }, status: http.StatusServiceUnavailable}, + {name: "not-ready", factory: func(kouja.Config) (*kouja.Client, error) { + return kouja.NewClientFromService(probeKoujaNotReadyAPI{}), nil + }, status: http.StatusOK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gw, err := gateway.New(st, fakeReadiness{status: readyStatus()}, gateway.WithKoujaClientFactory(test.factory)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(gw.Close) + server := httptest.NewServer(gw.Handler) + t.Cleanup(server.Close) + response, err := http.Post(server.URL+"/api/storage-backends/kouja/probe", "application/json", strings.NewReader(koujaProbePEMBody(t))) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != test.status { + t.Fatalf("status = %d, body = %s", response.StatusCode, readBody(t, response)) + } + if test.status == http.StatusOK { + var body map[string]any + decodeBody(t, response, &body) + if ready, ok := body["ready"]; ok && ready != false { + t.Fatalf("not-ready body = %+v", body) + } + if !strings.Contains(body["detail"].(string), "not ready") { + t.Fatalf("not-ready body = %+v", body) + } + } else { + _ = response.Body.Close() + } + }) + } +} diff --git a/internal/gateway/disk_writes.go b/internal/gateway/disk_writes.go index ed943ca..18835ab 100644 --- a/internal/gateway/disk_writes.go +++ b/internal/gateway/disk_writes.go @@ -143,8 +143,9 @@ func (s *diskService) CreateDisk(ctx context.Context, request *rookeryv1.CreateD } else if !errors.Is(err, store.ErrDiskNotFound) { return nil, status.Error(codes.Internal, "could not check disk name") } + readBPS, writeBPS := requestDiskBandwidth(request) disk := store.Disk{ - Name: name, SizeGB: sizeGB, + Name: name, SizeGB: sizeGB, ReadBPS: readBPS, WriteBPS: writeBPS, StorageBackendID: &backend.ID, StorageBackendName: backend.Name, VG: backend.Config.VG, ThinPool: backend.Config.ThinPool, Device: "/dev/" + backend.Config.VG + "/" + name, @@ -244,8 +245,10 @@ func (s *diskService) createHostDeviceDisk(ctx context.Context, request *rookery if sizeGB < 1 { sizeGB = 1 } + readBPS, writeBPS := requestDiskBandwidth(request) disk := store.Disk{ Name: name, SizeGB: sizeGB, SizeBytes: device.SizeBytes, + ReadBPS: readBPS, WriteBPS: writeBPS, Device: device.Path, Source: store.DiskSourceHostDevice, } created, err := s.store.CreateDisk(ctx, disk) @@ -313,10 +316,18 @@ func (s *diskService) UpdateDisk(ctx context.Context, request *rookeryv1.UpdateD if request.GetIsTemplate() || hasProvisioning { return nil, status.Error(codes.InvalidArgument, "host devices cannot be templates or template clones") } + if readBPS, writeBPS, update := applyUpdateBandwidth(request, disk); update { + updated, err := s.store.SetDiskBandwidth(ctx, disk.ID, readBPS, writeBPS) + if err != nil { + return nil, diskUpdateError(err) + } + return diskToProto(updated), nil + } return diskToProto(disk), nil } hasProvisioning := request.ProvisioningScript != nil || request.ProvisioningTimeout != nil - if request.IsTemplate == nil && !hasProvisioning { + hasBandwidth := request.ReadBps != nil || request.WriteBps != nil + if request.IsTemplate == nil && !hasProvisioning && !hasBandwidth { return diskToProto(disk), nil } targetTemplate := disk.IsTemplate @@ -359,6 +370,12 @@ func (s *diskService) UpdateDisk(ctx context.Context, request *rookeryv1.UpdateD return nil, diskUpdateError(err) } } + if readBPS, writeBPS, update := applyUpdateBandwidth(request, updated); update { + updated, err = s.store.SetDiskBandwidth(ctx, updated.ID, readBPS, writeBPS) + if err != nil { + return nil, diskUpdateError(err) + } + } return diskToProto(updated), nil } @@ -620,3 +637,28 @@ func normalizeImportNames(names []string) ([]string, error) { } return out, nil } + +func requestDiskBandwidth(request *rookeryv1.CreateDiskRequest) (int64, int64) { + var readBPS, writeBPS int64 + if request.ReadBps != nil { + readBPS = request.GetReadBps() + } + if request.WriteBps != nil { + writeBPS = request.GetWriteBps() + } + return readBPS, writeBPS +} + +func applyUpdateBandwidth(request *rookeryv1.UpdateDiskRequest, disk store.Disk) (int64, int64, bool) { + if request.ReadBps == nil && request.WriteBps == nil { + return disk.ReadBPS, disk.WriteBPS, false + } + readBPS, writeBPS := disk.ReadBPS, disk.WriteBPS + if request.ReadBps != nil { + readBPS = request.GetReadBps() + } + if request.WriteBps != nil { + writeBPS = request.GetWriteBps() + } + return readBPS, writeBPS, true +} diff --git a/internal/gateway/disk_writes_test.go b/internal/gateway/disk_writes_test.go index 220f146..da680c0 100644 --- a/internal/gateway/disk_writes_test.go +++ b/internal/gateway/disk_writes_test.go @@ -87,3 +87,59 @@ func TestDiskWriteRoutes(t *testing.T) { t.Fatalf("deleted volumes = %+v", provisioner.deleted) } } + +func TestDiskBandwidthRoutes(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "disk-bandwidth.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + provisioner := &fakeDiskProvisioner{} + gw, err := gateway.New(st, fakeReadiness{status: readiness.Status{ + Ready: true, + ThinPool: readiness.ThinPoolStatus{ + ComponentStatus: readiness.ComponentStatus{Ready: true}, + VG: "vg0", + Name: "pool", + }, + }}, gateway.WithDiskProvisioner(provisioner)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(gw.Close) + server := httptest.NewServer(gw.Handler) + t.Cleanup(server.Close) + + response, err := http.Post(server.URL+"/api/disks", "application/json", strings.NewReader( + `{"name":"limited","size_gb":10,"read_bps":10485760,"write_bps":5242880}`)) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", response.StatusCode, readBody(t, response)) + } + var disk map[string]any + decodeBody(t, response, &disk) + id := jsonID(disk["id"]) + if disk["read_bps"].(float64) != 10485760 || disk["write_bps"].(float64) != 5242880 { + t.Fatalf("created disk = %+v", disk) + } + + request, err := http.NewRequest(http.MethodPut, server.URL+"/api/disks/"+id, strings.NewReader(`{"read_bps":3145728}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + updated, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + if updated.StatusCode != http.StatusOK { + t.Fatalf("update status = %d, body = %s", updated.StatusCode, readBody(t, updated)) + } + var updatedDisk map[string]any + decodeBody(t, updated, &updatedDisk) + if updatedDisk["read_bps"].(float64) != 3145728 || updatedDisk["write_bps"].(float64) != 5242880 { + t.Fatalf("updated disk = %+v", updatedDisk) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 6fc9bfb..87502bd 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -24,6 +24,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/readiness" "github.com/voidstarit/rookery/internal/storage" "github.com/voidstarit/rookery/internal/store" @@ -77,6 +78,7 @@ type options struct { lightvmRuntime LightVMRuntime storageRegistry *storage.Registry startPlanner *vmstart.Planner + koujaClientFactory func(kouja.Config) (*kouja.Client, error) } // WithGRPCListener exposes the generated gRPC server on listener instead of an @@ -141,6 +143,14 @@ func WithStartPlanner(planner *vmstart.Planner) Option { } } +// WithKoujaClientFactory overrides kouja client construction, primarily for +// connection probes and tests. +func WithKoujaClientFactory(factory func(kouja.Config) (*kouja.Client, error)) Option { + return func(opts *options) { + opts.koujaClientFactory = factory + } +} + // WithHostDeviceValidator overrides host block-device validation. Tests use it // to exercise passthrough disk registration without a real block device. func WithHostDeviceValidator(validator HostDeviceValidator) Option { @@ -324,7 +334,7 @@ func New(st *store.Store, ready Readiness, opts ...Option) (*Gateway, error) { acls := &aclService{store: st, runtime: options.aclRuntime, readiness: ready} routeTables := &routeTableService{store: st, runtime: options.aclRuntime, readiness: ready} vnfs := &vnfService{store: st, runtime: options.vnfRuntime, statusProvider: options.vnfStatusProvider, writer: options.vnfWriter} - backends := &storageBackendService{store: st, readiness: ready, disks: options.diskProvisioner, mu: options.diskMutex, registry: options.storageRegistry} + backends := &storageBackendService{store: st, readiness: ready, disks: options.diskProvisioner, mu: options.diskMutex, registry: options.storageRegistry, newKoujaClient: options.koujaClientFactory} hostInterfaces := &hostService{source: options.vppInterfaces, store: st, writer: options.hostWriter, taps: options.hostTapWriter, devices: options.hostDeviceLister, addresses: options.interfaceAddresses, readiness: ready} troubleshoot := &troubleshootService{runtime: options.diagnostics, readiness: ready} captures := &captureService{runtime: options.diagnostics, readiness: ready} @@ -594,7 +604,7 @@ func (s *vmService) ListVMs(ctx context.Context, _ *rookeryv1.ListVMsRequest) (* } response := &rookeryv1.ListVMsResponse{Vms: make([]*rookeryv1.VM, 0, len(vms))} for _, vm := range vms { - response.Vms = append(response.Vms, vmToProto(vm)) + response.Vms = append(response.Vms, s.vmToProto(ctx, vm)) } return response, nil } @@ -607,7 +617,7 @@ func (s *vmService) GetVM(ctx context.Context, request *rookeryv1.GetVMRequest) case err != nil: return nil, status.Error(codes.Internal, "could not read VM") default: - return vmToProto(vm), nil + return s.vmToProto(ctx, vm), nil } } @@ -657,6 +667,6 @@ func vmDiskToProto(disk store.VMDisk) *rookeryv1.VMDisk { Id: disk.ID, Name: disk.Name, SizeGb: int32(disk.SizeGB), SizeBytes: disk.SizeBytes, StorageBackendId: disk.StorageBackendID, StorageBackendName: disk.StorageBackendName, Vg: disk.VG, ThinPool: disk.ThinPool, Device: disk.Device, Source: disk.Source, - Protocol: disk.Protocol, + Protocol: disk.Protocol, ReadBps: disk.ReadBPS, WriteBps: disk.WriteBPS, } } diff --git a/internal/gateway/kouja_branches_internal_test.go b/internal/gateway/kouja_branches_internal_test.go index 200da5f..5067da5 100644 --- a/internal/gateway/kouja_branches_internal_test.go +++ b/internal/gateway/kouja_branches_internal_test.go @@ -3,7 +3,9 @@ package gateway import ( "context" "errors" + "fmt" "path/filepath" + "strings" "sync" "testing" @@ -13,6 +15,7 @@ import ( rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" "github.com/voidstarit/rookery/internal/blockdev" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/qemu" "github.com/voidstarit/rookery/internal/readiness" "github.com/voidstarit/rookery/internal/storage" @@ -240,3 +243,10 @@ func (r *legacyRuntime) Reset(context.Context, string) error { return n func (r *legacyRuntime) SendCtrlAltDel(context.Context, string) error { return nil } func (r *legacyRuntime) Running(string) bool { return false } func (r *legacyRuntime) Metrics(string) (qemu.Metrics, error) { return qemu.Metrics{}, nil } + +func TestVMStartStatusErrorSessionActive(t *testing.T) { + err := vmStartStatusError(fmt.Errorf("prepare disk: %w", kouja.ErrSessionActive)) + if status.Code(err) != codes.Unavailable || !strings.Contains(err.Error(), "previous iSCSI session") { + t.Fatalf("vmStartStatusError = %v", err) + } +} diff --git a/internal/gateway/readonly.go b/internal/gateway/readonly.go index 319a932..223e604 100644 --- a/internal/gateway/readonly.go +++ b/internal/gateway/readonly.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/storage" "github.com/voidstarit/rookery/internal/store" ) @@ -70,6 +71,7 @@ func diskToProto(disk store.Disk) *rookeryv1.Disk { ProvisioningScript: disk.ProvisioningScript, ProvisioningTimeout: disk.ProvisioningTimeout, AttachedVmId: disk.AttachedVMID, AttachedVmName: disk.AttachedVMName, Locator: disk.BackendMetadata.Attachment.Locator(), Protocol: disk.Protocol, + ReadBps: disk.ReadBPS, WriteBps: disk.WriteBPS, DeletionState: disk.DeletionState, DeletionError: disk.DeletionError, CreatedAt: timestamp(disk.CreatedAt), UpdatedAt: timestamp(disk.UpdatedAt), } @@ -378,11 +380,12 @@ func vnfToProto(vnf store.VNF) *rookeryv1.VNF { type storageBackendService struct { rookeryv1.UnimplementedStorageBackendServiceServer - store *store.Store - readiness Readiness - disks DiskProvisioner - mu *sync.Mutex - registry *storage.Registry + store *store.Store + readiness Readiness + disks DiskProvisioner + mu *sync.Mutex + registry *storage.Registry + newKoujaClient func(kouja.Config) (*kouja.Client, error) } func (s *storageBackendService) ListStorageBackends(ctx context.Context, _ *rookeryv1.ListStorageBackendsRequest) (*rookeryv1.ListStorageBackendsResponse, error) { @@ -419,7 +422,9 @@ func backendConfigToProto(config store.StorageBackendConfig) *rookeryv1.StorageB StorageClass: config.Kouja.StorageClass, Initiator: config.Kouja.Initiator, HostNqn: config.Kouja.HostNQN, CaFile: config.Kouja.CAFile, ClientCertFile: config.Kouja.ClientCertFile, ClientKeyFile: config.Kouja.ClientKeyFile, - ServerName: config.Kouja.ServerName, Portal: config.Kouja.Portal, + CaPem: config.Kouja.CAPEM, ClientCertPem: config.Kouja.ClientCertPEM, + ClientKeyPem: config.Kouja.ClientKeyPEM, + ServerName: config.Kouja.ServerName, Portal: config.Kouja.Portal, }} } return result diff --git a/internal/gateway/storage_backend_health.go b/internal/gateway/storage_backend_health.go index a99b8b6..68ddcb2 100644 --- a/internal/gateway/storage_backend_health.go +++ b/internal/gateway/storage_backend_health.go @@ -5,10 +5,13 @@ import ( "errors" "time" + koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" + "github.com/voidstarit/rookery/internal/blockdev" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/store" ) @@ -66,6 +69,54 @@ func (s *storageBackendService) GetStorageBackendHealth(ctx context.Context, req return s.withDeletionBacklog(ctx, health) } +func (s *storageBackendService) ProbeKoujaBackend(ctx context.Context, request *rookeryv1.ProbeKoujaBackendRequest) (*rookeryv1.ProbeKoujaBackendResponse, error) { + cfg := koujaConfigFromProto(request.GetConfig()) + parsed, err := kouja.ParseConnectionConfig(store.StorageBackendConfig{Kouja: cfg}) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + factory := s.newKoujaClient + if factory == nil { + factory = kouja.NewClient + } + client, err := factory(parsed) + if err != nil { + return nil, status.Error(codes.Unavailable, "could not connect to kouja: "+err.Error()) + } + defer client.Close() + info, err := client.GetInfo(ctx, &koujav1.GetInfoRequest{}) + if err != nil { + return nil, status.Error(codes.Unavailable, "could not connect to kouja: "+err.Error()) + } + classes, err := client.ListStorageClasses(ctx, &koujav1.ListStorageClassesRequest{}) + if err != nil { + return nil, status.Error(codes.Unavailable, "could not list kouja storage classes: "+err.Error()) + } + response := &rookeryv1.ProbeKoujaBackendResponse{ + Reachable: true, Ready: info.GetReady(), Owner: parsed.Owner, + Detail: "connected", + } + if !info.GetReady() { + response.Detail = "connected; kouja agent is not ready" + } + for _, protocol := range info.GetProtocols() { + switch protocol { + case koujav1.Protocol_PROTOCOL_ISCSI: + response.Protocols = append(response.Protocols, string(blockdev.ProtocolISCSI)) + case koujav1.Protocol_PROTOCOL_NVME_TCP: + response.Protocols = append(response.Protocols, string(blockdev.ProtocolNVMeTCP)) + default: + response.Protocols = append(response.Protocols, protocol.String()) + } + } + for _, class := range classes.GetClasses() { + if name := class.GetName(); name != "" { + response.StorageClasses = append(response.StorageClasses, name) + } + } + return response, nil +} + func (s *storageBackendService) TestStorageBackendDataPath(context.Context, *rookeryv1.TestStorageBackendDataPathRequest) (*rookeryv1.StorageBackendHealth, error) { return nil, status.Error(codes.Unimplemented, "deep storage data-path testing is not implemented yet") } diff --git a/internal/gateway/vm_actions.go b/internal/gateway/vm_actions.go index 5eeda17..967a065 100644 --- a/internal/gateway/vm_actions.go +++ b/internal/gateway/vm_actions.go @@ -3,6 +3,8 @@ package gateway import ( "context" "errors" + "strconv" + "strings" "time" "google.golang.org/grpc/codes" @@ -12,6 +14,7 @@ import ( rookeryv1 "github.com/voidstarit/rookery/design/gen/rookery/v1" "github.com/voidstarit/rookery/internal/host" + "github.com/voidstarit/rookery/internal/kouja" "github.com/voidstarit/rookery/internal/qemu" "github.com/voidstarit/rookery/internal/store" ) @@ -26,6 +29,17 @@ type VMController interface { Metrics(vmID string) (qemu.Metrics, error) } +// VMStateMonitor exposes the live QEMU monitor state and pause recovery. +type VMStateMonitor interface { + RunState(ctx context.Context, vmID string) (string, error) + Resume(ctx context.Context, vmID string) (string, error) +} + +// VMBlockStats exposes per-drive QEMU block counters. +type VMBlockStats interface { + BlockStats(ctx context.Context, vmID string) ([]qemu.BlockDeviceStats, error) +} + // CgroupUpdater applies cgroup changes to a running VM without restarting // QEMU. Implementations move the QEMU process between child cgroups as needed. type CgroupUpdater interface { @@ -38,6 +52,25 @@ type InterfaceCounterSource interface { ResetInterfaceCounters(ctx context.Context) error } +func vmStartStatusError(err error) error { + if errors.Is(err, kouja.ErrSessionActive) { + return status.Error(codes.Unavailable, "could not start VM: "+err.Error()+" (retrying after the previous iSCSI session closes usually succeeds)") + } + return status.Error(codes.Internal, "could not start VM: "+err.Error()) +} + +func (s *vmService) vmToProto(ctx context.Context, vm store.VM) *rookeryv1.VM { + result := vmToProto(vm) + if vm.Status == store.StatusRunning && s.runtime != nil { + if monitor, ok := s.runtime.(VMStateMonitor); ok { + if state, err := monitor.RunState(ctx, vm.ID); err == nil { + result.RunState = state + } + } + } + return result +} + func (s *vmService) startVM(ctx context.Context, vm store.VM) error { if s.planner != nil { plan, err := s.planner.Plan(ctx, vm) @@ -91,7 +124,7 @@ func (s *vmService) StartVM(ctx context.Context, request *rookeryv1.StartVMReque } if err := s.startVM(ctx, vm); err != nil { _, _ = s.store.SetVMStatus(ctx, vm.ID, store.StatusError) - return nil, status.Error(codes.Internal, "could not start VM: "+err.Error()) + return nil, vmStartStatusError(err) } if _, err := s.store.SetVMStatus(ctx, vm.ID, store.StatusRunning); err != nil { return nil, status.Error(codes.Internal, "could not update VM status") @@ -100,7 +133,7 @@ func (s *vmService) StartVM(ctx context.Context, request *rookeryv1.StartVMReque if err != nil { return nil, status.Error(codes.Internal, "could not update VM desired state") } - return vmToProto(updated), nil + return s.vmToProto(ctx, updated), nil } func (s *vmService) StopVM(ctx context.Context, request *rookeryv1.StopVMRequest) (*rookeryv1.VM, error) { @@ -127,7 +160,39 @@ func (s *vmService) StopVM(ctx context.Context, request *rookeryv1.StopVMRequest if err != nil { return nil, status.Error(codes.Internal, "could not update VM desired state") } - return vmToProto(updated), nil + return s.vmToProto(ctx, updated), nil +} + +func (s *vmService) ResumeVM(ctx context.Context, request *rookeryv1.ResumeVMRequest) (*rookeryv1.VM, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.runtimeUnavailable(); err != nil { + return nil, err + } + vm, err := s.store.GetVM(ctx, request.GetId()) + switch { + case errors.Is(err, store.ErrNotFound): + return nil, status.Error(codes.NotFound, "vm not found") + case err != nil: + return nil, status.Error(codes.Internal, "could not read VM") + } + if !s.runtime.Running(vm.ID) { + return nil, status.Error(codes.FailedPrecondition, "VM is not running") + } + monitor, ok := s.runtime.(VMStateMonitor) + if !ok { + return nil, status.Error(codes.Unimplemented, "VM runtime does not expose monitor state") + } + state, err := monitor.Resume(ctx, vm.ID) + if err != nil { + if errors.Is(err, qemu.ErrNotPaused) { + return nil, status.Error(codes.FailedPrecondition, "VM is not paused") + } + return nil, status.Error(codes.Internal, "could not resume VM: "+err.Error()) + } + result := vmToProto(vm) + result.RunState = state + return result, nil } func (s *vmService) ResetVM(ctx context.Context, request *rookeryv1.ResetVMRequest) (*rookeryv1.VM, error) { @@ -188,6 +253,29 @@ func (s *vmService) CtrlAltDel(ctx context.Context, request *rookeryv1.CtrlAltDe return s.getVM(ctx, request.GetId()) } +func blockDeviceToProto(vm store.VM, stat qemu.BlockDeviceStats) *rookeryv1.BlockDeviceMetrics { + result := &rookeryv1.BlockDeviceMetrics{ + Device: stat.Device, + ReadBytes: stat.ReadBytes, WriteBytes: stat.WriteBytes, + ReadOperations: stat.ReadOperations, WriteOperations: stat.WriteOperations, + FailedReadOperations: stat.FailedReadOps, FailedWriteOperations: stat.FailedWriteOps, + InvalidReadOperations: stat.InvalidReadOps, InvalidWriteOperations: stat.InvalidWriteOps, + } + const prefix = "rookery-disk-" + if strings.HasPrefix(stat.Device, prefix) { + if id, err := strconv.ParseInt(strings.TrimPrefix(stat.Device, prefix), 10, 64); err == nil { + result.DiskId = id + for _, disk := range vm.Disks { + if disk.ID == id { + result.Name = disk.Name + break + } + } + } + } + return result +} + func (s *vmService) GetVMMetrics(ctx context.Context, request *rookeryv1.GetVMMetricsRequest) (*rookeryv1.VMMetrics, error) { if err := s.runtimeUnavailable(); err != nil { return nil, err @@ -218,6 +306,15 @@ func (s *vmService) GetVMMetrics(ctx context.Context, request *rookeryv1.GetVMMe MemoryMb: int32(vm.MemoryMB), SampledAt: timestamppb.New(time.Now().UTC()), } + if metrics.Running { + if blockSource, ok := s.runtime.(VMBlockStats); ok { + if blockStats, blockErr := blockSource.BlockStats(ctx, vm.ID); blockErr == nil { + for _, stat := range blockStats { + result.BlockDevices = append(result.BlockDevices, blockDeviceToProto(vm, stat)) + } + } + } + } if metrics.Running && s.counters != nil && len(vm.Interfaces) > 0 { names := make([]string, 0, len(vm.Interfaces)) for _, iface := range vm.Interfaces { @@ -240,7 +337,7 @@ func (s *vmService) getVM(ctx context.Context, id string) (*rookeryv1.VM, error) if err != nil { return nil, status.Error(codes.Internal, "could not read VM") } - return vmToProto(vm), nil + return s.vmToProto(ctx, vm), nil } func (s *vmService) DeleteVM(ctx context.Context, request *rookeryv1.DeleteVMRequest) (*emptypb.Empty, error) { diff --git a/internal/gateway/vm_actions_test.go b/internal/gateway/vm_actions_test.go index 2d9bb3e..8eb4898 100644 --- a/internal/gateway/vm_actions_test.go +++ b/internal/gateway/vm_actions_test.go @@ -2,6 +2,7 @@ package gateway_test import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -24,6 +25,10 @@ type fakeVMController struct { cgroupErr error cgroupApplied []store.VM plan qemu.StartPlan + runState string + resumeErr error + blockStats []qemu.BlockDeviceStats + blockErr error } func (f *fakeVMController) Start(_ context.Context, _ store.VM) error { @@ -37,6 +42,19 @@ func (f *fakeVMController) StartWithPlan(ctx context.Context, vm store.VM, plan f.plan = plan return f.Start(ctx, vm) } +func (f *fakeVMController) RunState(context.Context, string) (string, error) { + return f.runState, nil +} +func (f *fakeVMController) BlockStats(context.Context, string) ([]qemu.BlockDeviceStats, error) { + return f.blockStats, f.blockErr +} +func (f *fakeVMController) Resume(_ context.Context, _ string) (string, error) { + if f.resumeErr != nil { + return "", f.resumeErr + } + f.runState = "running" + return f.runState, nil +} func (f *fakeVMController) Stop(_ context.Context, _ string) error { f.running = false return nil @@ -77,10 +95,16 @@ func TestVMActionsAndMetrics(t *testing.T) { t.Fatal(err) } - runtime := &fakeVMController{metrics: qemu.Metrics{ - Running: true, PID: 1234, UptimeSeconds: 12, CPUSecondsTotal: 2.5, - RSSBytes: 1024, VirtualBytes: 2048, ReadBytesTotal: 10, WriteBytesTotal: 20, - }} + runtime := &fakeVMController{ + metrics: qemu.Metrics{ + Running: true, PID: 1234, UptimeSeconds: 12, CPUSecondsTotal: 2.5, + RSSBytes: 1024, VirtualBytes: 2048, ReadBytesTotal: 10, WriteBytesTotal: 20, + }, + blockStats: []qemu.BlockDeviceStats{{ + Device: fmt.Sprintf("rookery-disk-%d", disk.ID), ReadBytes: 100, WriteBytes: 200, + FailedReadOps: 1, FailedWriteOps: 2, + }}, + } gw, err := gateway.New(st, fakeReadiness{status: readyStatus()}, gateway.WithVMController(runtime)) if err != nil { t.Fatal(err) @@ -127,6 +151,10 @@ func TestVMActionsAndMetrics(t *testing.T) { if !metrics.GetRunning() || metrics.GetVmId() != vm.ID || metrics.GetPid() != 1234 || metrics.GetCpuSecondsTotal() != 2.5 { t.Fatalf("metrics = running=%v vm_id=%q pid=%d cpu=%v", metrics.GetRunning(), metrics.GetVmId(), metrics.GetPid(), metrics.GetCpuSecondsTotal()) } + if len(metrics.GetBlockDevices()) != 1 || metrics.GetBlockDevices()[0].GetName() != "vm-disk" || + metrics.GetBlockDevices()[0].GetFailedWriteOperations() != 2 { + t.Fatalf("block devices = %+v", metrics.GetBlockDevices()) + } ctrlAltDel := post("/api/vms/" + vm.ID + "/ctrl-alt-del") if ctrlAltDel.StatusCode != http.StatusNoContent { @@ -229,3 +257,67 @@ func TestStartVMRepairsStatusWhenQEMUIsAlreadyRunning(t *testing.T) { t.Fatalf("stored status = %q, want running", loaded.Status) } } + +func TestVMResumeAndRunStateRoutes(t *testing.T) { + ctx := context.Background() + st, err := store.Open(filepath.Join(t.TempDir(), "vm-resume.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + disk, err := st.CreateDisk(ctx, store.Disk{Name: "root", SizeGB: 5, VG: "vg0", ThinPool: "pool"}) + if err != nil { + t.Fatal(err) + } + vm, err := st.CreateVM(ctx, store.VM{Name: "paused-vm", VCPUs: 1, MemoryMB: 512, DiskIDs: []int64{disk.ID}}) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetVMStatus(ctx, vm.ID, store.StatusRunning); err != nil { + t.Fatal(err) + } + runtime := &fakeVMController{running: true, runState: "paused (io-error)"} + gw, err := gateway.New(st, fakeReadiness{status: readyStatus()}, gateway.WithVMController(runtime)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(gw.Close) + server := httptest.NewServer(gw.Handler) + t.Cleanup(server.Close) + + response, err := http.Get(server.URL + "/api/vms/" + vm.ID) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("get vm = %d, %s", response.StatusCode, readBody(t, response)) + } + var body map[string]any + decodeBody(t, response, &body) + if body["run_state"] != "paused (io-error)" { + t.Fatalf("vm body = %+v", body) + } + + resume, err := http.Post(server.URL+"/api/vms/"+vm.ID+"/resume", "application/json", nil) + if err != nil { + t.Fatal(err) + } + if resume.StatusCode != http.StatusOK { + t.Fatalf("resume = %d, %s", resume.StatusCode, readBody(t, resume)) + } + decodeBody(t, resume, &body) + if body["run_state"] != "running" { + t.Fatalf("resumed body = %+v", body) + } + + runtime.resumeErr = qemu.ErrNotPaused + runtime.runState = "running" + failed, err := http.Post(server.URL+"/api/vms/"+vm.ID+"/resume", "application/json", nil) + if err != nil { + t.Fatal(err) + } + if failed.StatusCode != http.StatusBadRequest { + t.Fatalf("resume not paused = %d, %s", failed.StatusCode, readBody(t, failed)) + } + _ = failed.Body.Close() +} diff --git a/internal/gateway/vm_create.go b/internal/gateway/vm_create.go index 2de666e..19375fe 100644 --- a/internal/gateway/vm_create.go +++ b/internal/gateway/vm_create.go @@ -83,7 +83,7 @@ func (s *vmService) CreateVM(ctx context.Context, request *rookeryv1.CreateVMReq if err != nil { return nil, err } - return vmToProto(created), nil + return s.vmToProto(ctx, created), nil } // startCreatedVM starts a freshly created VM. Rookery creates VMs running; @@ -99,7 +99,7 @@ func (s *vmService) startCreatedVM(ctx context.Context, vm store.VM) (store.VM, defer s.mu.Unlock() if err := s.startVM(ctx, vm); err != nil { s.rollbackCreatedVM(vm.ID) - return store.VM{}, status.Error(codes.Internal, "could not start VM: "+err.Error()) + return store.VM{}, vmStartStatusError(err) } if _, err := s.store.SetVMStatus(ctx, vm.ID, store.StatusRunning); err != nil { s.rollbackCreatedVM(vm.ID) @@ -229,7 +229,7 @@ func (s *vmService) UpdateVM(ctx context.Context, request *rookeryv1.UpdateVMReq return nil, vmStoreError(err, "update disks") } } - return vmToProto(updated), nil + return s.vmToProto(ctx, updated), nil } // checkVMCreatePrerequisites only requires the thin pool for disks that diff --git a/internal/gateway/writes.go b/internal/gateway/writes.go index a303867..5c03c84 100644 --- a/internal/gateway/writes.go +++ b/internal/gateway/writes.go @@ -116,14 +116,7 @@ func normalizeStorageBackendFields(name, title, description, driver string, conf if config == nil || config.GetKouja() == nil { return store.StorageBackend{}, errors.New("kouja configuration is required") } - raw := config.GetKouja() - cfg := &store.KoujaConfig{ - Endpoint: strings.TrimSpace(raw.GetEndpoint()), Protocol: strings.TrimSpace(raw.GetProtocol()), - StorageClass: strings.TrimSpace(raw.GetStorageClass()), Initiator: strings.TrimSpace(raw.GetInitiator()), - HostNQN: strings.TrimSpace(raw.GetHostNqn()), CAFile: strings.TrimSpace(raw.GetCaFile()), - ClientCertFile: strings.TrimSpace(raw.GetClientCertFile()), ClientKeyFile: strings.TrimSpace(raw.GetClientKeyFile()), - ServerName: strings.TrimSpace(raw.GetServerName()), Portal: strings.TrimSpace(raw.GetPortal()), - } + cfg := koujaConfigFromProto(config.GetKouja()) if _, err := kouja.ParseConfig(store.StorageBackendConfig{Kouja: cfg}); err != nil { return store.StorageBackend{}, err } @@ -265,3 +258,18 @@ func (s *mediaService) DeleteMedia(ctx context.Context, request *rookeryv1.Delet } return &emptypb.Empty{}, nil } + +func koujaConfigFromProto(raw *rookeryv1.KoujaConfig) *store.KoujaConfig { + if raw == nil { + return nil + } + return &store.KoujaConfig{ + Endpoint: strings.TrimSpace(raw.GetEndpoint()), Protocol: strings.TrimSpace(raw.GetProtocol()), + StorageClass: strings.TrimSpace(raw.GetStorageClass()), Initiator: strings.TrimSpace(raw.GetInitiator()), + HostNQN: strings.TrimSpace(raw.GetHostNqn()), CAFile: strings.TrimSpace(raw.GetCaFile()), + ClientCertFile: strings.TrimSpace(raw.GetClientCertFile()), ClientKeyFile: strings.TrimSpace(raw.GetClientKeyFile()), + CAPEM: strings.TrimSpace(raw.GetCaPem()), ClientCertPEM: strings.TrimSpace(raw.GetClientCertPem()), + ClientKeyPEM: strings.TrimSpace(raw.GetClientKeyPem()), + ServerName: strings.TrimSpace(raw.GetServerName()), Portal: strings.TrimSpace(raw.GetPortal()), + } +} diff --git a/internal/kouja/client.go b/internal/kouja/client.go index f31e154..47c8ea7 100644 --- a/internal/kouja/client.go +++ b/internal/kouja/client.go @@ -2,6 +2,7 @@ package kouja import ( "context" + "crypto/tls" koujacli "github.com/voidstarit/kouja/gen/client" koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" @@ -29,7 +30,7 @@ type Client struct { // NewClient creates a kouja mTLS client for cfg. func NewClient(cfg Config) (*Client, error) { - tlsConfig, err := koujacli.LoadTLSConfig(cfg.ClientCertFile, cfg.ClientKeyFile, cfg.CAFile) + tlsConfig, err := tlsConfigForConfig(cfg) if err != nil { return nil, err } @@ -43,6 +44,13 @@ func NewClient(cfg Config) (*Client, error) { return &Client{API: grpcClient, close: grpcClient.Close}, nil } +func tlsConfigForConfig(cfg Config) (*tls.Config, error) { + if cfg.CAPEM != "" || cfg.ClientCertPEM != "" || cfg.ClientKeyPEM != "" { + return tlsConfigFromPEM(cfg.CAPEM, cfg.ClientCertPEM, cfg.ClientKeyPEM) + } + return koujacli.LoadTLSConfig(cfg.ClientCertFile, cfg.ClientKeyFile, cfg.CAFile) +} + // NewClientFromService wraps an existing service client. It is intended for // tests and for callers that own the gRPC connection. func NewClientFromService(service API) *Client { diff --git a/internal/kouja/client_test.go b/internal/kouja/client_test.go index 8a9401c..0230558 100644 --- a/internal/kouja/client_test.go +++ b/internal/kouja/client_test.go @@ -63,3 +63,14 @@ func TestNewClientInvalidTLS(t *testing.T) { t.Fatal("NewClient() accepted missing TLS files") } } + +func TestTLSConfigFromPEM(t *testing.T) { + cfg := Config{ + CAPEM: "-----BEGIN CERTIFICATE-----\nnot-a-cert\n-----END CERTIFICATE-----\n", + ClientCertPEM: "-----BEGIN CERTIFICATE-----\nnot-a-cert\n-----END CERTIFICATE-----\n", + ClientKeyPEM: "-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----\n", + } + if _, err := tlsConfigForConfig(cfg); err == nil { + t.Fatal("tlsConfigForConfig accepted invalid PEM content") + } +} diff --git a/internal/kouja/config.go b/internal/kouja/config.go index 5a69f7f..a94c3ac 100644 --- a/internal/kouja/config.go +++ b/internal/kouja/config.go @@ -33,6 +33,9 @@ type Config struct { CAFile string ClientCertFile string ClientKeyFile string + CAPEM string + ClientCertPEM string + ClientKeyPEM string ServerName string Portal string Owner string @@ -40,6 +43,17 @@ type Config struct { // ParseConfig validates a stored kouja backend config. func ParseConfig(cfg store.StorageBackendConfig) (Config, error) { + return parseConfig(cfg, true) +} + +// ParseConnectionConfig validates the connection and certificate fields without +// requiring a storage class. It is used by the UI connection probe before the +// operator has selected a class. +func ParseConnectionConfig(cfg store.StorageBackendConfig) (Config, error) { + return parseConfig(cfg, false) +} + +func parseConfig(cfg store.StorageBackendConfig, requireStorageClass bool) (Config, error) { if cfg.Kouja == nil { return Config{}, ErrNotConfigured } @@ -60,34 +74,58 @@ func ParseConfig(cfg store.StorageBackendConfig) (Config, error) { CAFile: strings.TrimSpace(raw.CAFile), ClientCertFile: strings.TrimSpace(raw.ClientCertFile), ClientKeyFile: strings.TrimSpace(raw.ClientKeyFile), + CAPEM: strings.TrimSpace(raw.CAPEM), + ClientCertPEM: strings.TrimSpace(raw.ClientCertPEM), + ClientKeyPEM: strings.TrimSpace(raw.ClientKeyPEM), ServerName: strings.TrimSpace(raw.ServerName), Portal: strings.TrimSpace(raw.Portal), } if err := validateEndpoint(parsed.Endpoint); err != nil { return Config{}, err } - if parsed.StorageClass == "" { + if requireStorageClass && parsed.StorageClass == "" { return Config{}, errors.New("kouja storage_class is required") } if len(parsed.StorageClass) > 127 { return Config{}, errors.New("kouja storage_class is too long") } - for name, path := range map[string]string{ - "ca_file": parsed.CAFile, "client_cert_file": parsed.ClientCertFile, "client_key_file": parsed.ClientKeyFile, - } { - if path == "" || !filepath.IsAbs(path) { - return Config{}, fmt.Errorf("%s must be an absolute path", name) + hasPEM := parsed.CAPEM != "" || parsed.ClientCertPEM != "" || parsed.ClientKeyPEM != "" + if hasPEM { + if parsed.CAPEM == "" || parsed.ClientCertPEM == "" || parsed.ClientKeyPEM == "" { + return Config{}, errors.New("ca_pem, client_cert_pem, and client_key_pem must be provided together") } - } - if _, err := os.Stat(parsed.CAFile); err != nil { - return Config{}, fmt.Errorf("stat CA file: %w", err) - } - keyInfo, err := os.Stat(parsed.ClientKeyFile) - if err != nil { - return Config{}, fmt.Errorf("stat client key: %w", err) - } - if keyInfo.Mode().Perm()&0o004 != 0 { - return Config{}, errors.New("client_key_file must not be world-readable") + tlsConfig, err := tlsConfigFromPEM(parsed.CAPEM, parsed.ClientCertPEM, parsed.ClientKeyPEM) + if err != nil { + return Config{}, err + } + owner, err := ownerFromPair(tlsConfig.Certificates[0]) + if err != nil { + return Config{}, err + } + parsed.Owner = owner + } else { + for name, path := range map[string]string{ + "ca_file": parsed.CAFile, "client_cert_file": parsed.ClientCertFile, "client_key_file": parsed.ClientKeyFile, + } { + if path == "" || !filepath.IsAbs(path) { + return Config{}, fmt.Errorf("%s must be an absolute path", name) + } + } + if _, err := os.Stat(parsed.CAFile); err != nil { + return Config{}, fmt.Errorf("stat CA file: %w", err) + } + keyInfo, err := os.Stat(parsed.ClientKeyFile) + if err != nil { + return Config{}, fmt.Errorf("stat client key: %w", err) + } + if keyInfo.Mode().Perm()&0o004 != 0 { + return Config{}, errors.New("client_key_file must not be world-readable") + } + owner, err := ownerFromCertificate(parsed.ClientCertFile, parsed.ClientKeyFile) + if err != nil { + return Config{}, err + } + parsed.Owner = owner } switch protocol { case blockdev.ProtocolISCSI: @@ -104,11 +142,6 @@ func ParseConfig(cfg store.StorageBackendConfig) (Config, error) { return Config{}, err } } - owner, err := ownerFromCertificate(parsed.ClientCertFile, parsed.ClientKeyFile) - if err != nil { - return Config{}, err - } - parsed.Owner = owner return parsed, nil } @@ -169,11 +202,31 @@ func isNQN(value string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "nqn.") } +func tlsConfigFromPEM(caPEM, certPEM, keyPEM string) (*tls.Config, error) { + certificate, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + if err != nil { + return nil, fmt.Errorf("load kouja client certificate: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM([]byte(caPEM)) { + return nil, errors.New("ca_pem does not contain a certificate") + } + return &tls.Config{ //nolint:gosec // kouja requires TLS 1.3 and hostname verification + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + RootCAs: roots, + }, nil +} + func ownerFromCertificate(certFile, keyFile string) (string, error) { pair, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return "", fmt.Errorf("load kouja client certificate: %w", err) } + return ownerFromPair(pair) +} + +func ownerFromPair(pair tls.Certificate) (string, error) { if len(pair.Certificate) == 0 { return "", errors.New("kouja client certificate is empty") } diff --git a/internal/kouja/config_test.go b/internal/kouja/config_test.go index 4f228eb..b23f177 100644 --- a/internal/kouja/config_test.go +++ b/internal/kouja/config_test.go @@ -205,3 +205,58 @@ func cloneKouja(value *store.KoujaConfig) *store.KoujaConfig { copy := *value return © } + +func TestParseConfigPEMContent(t *testing.T) { + uri, _ := url.Parse("spiffe://kouja/caller/rookery") + certFile, keyFile, caFile := writeTestClientCert(t, []*url.URL{uri}) + certPEM, err := os.ReadFile(certFile) + if err != nil { + t.Fatal(err) + } + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + t.Fatal(err) + } + cfg := store.StorageBackendConfig{Kouja: &store.KoujaConfig{ + Endpoint: "10.0.0.1:9443", StorageClass: "nvme", + Initiator: "iqn.2026-11.io.example:rookery", + CAPEM: string(caPEM), ClientCertPEM: string(certPEM), ClientKeyPEM: string(keyPEM), + }} + parsed, err := ParseConfig(cfg) + if err != nil { + t.Fatalf("ParseConfig(PEM) = %v", err) + } + if parsed.Owner != "spiffe://kouja/caller/rookery" || parsed.CAPEM == "" { + t.Fatalf("parsed PEM config = %+v", parsed) + } + partial := store.StorageBackendConfig{Kouja: cloneKouja(cfg.Kouja)} + partial.Kouja.ClientKeyPEM = "" + if _, err := ParseConfig(partial); err == nil || !strings.Contains(err.Error(), "together") { + t.Fatalf("partial PEM config = %v", err) + } + badCA := store.StorageBackendConfig{Kouja: cloneKouja(cfg.Kouja)} + badCA.Kouja.CAPEM = "not a certificate" + if _, err := ParseConfig(badCA); err == nil { + t.Fatal("ParseConfig accepted a bad CA PEM") + } + badKey := store.StorageBackendConfig{Kouja: cloneKouja(cfg.Kouja)} + badKey.Kouja.ClientKeyPEM = "not a key" + if _, err := ParseConfig(badKey); err == nil { + t.Fatal("ParseConfig accepted a bad client key PEM") + } +} + +func TestParseConnectionConfigAllowsEmptyClass(t *testing.T) { + base := testKoujaConfig(t) + base.Kouja.StorageClass = "" + if _, err := ParseConnectionConfig(base); err != nil { + t.Fatalf("ParseConnectionConfig() = %v", err) + } + if _, err := ParseConfig(base); err == nil { + t.Fatal("ParseConfig accepted an empty storage class") + } +} diff --git a/internal/kouja/preparer.go b/internal/kouja/preparer.go index 559925f..3605b9c 100644 --- a/internal/kouja/preparer.go +++ b/internal/kouja/preparer.go @@ -27,6 +27,12 @@ var ( // ObservationFreshness is the maximum age for an observation to be trusted. const ObservationFreshness = 2 * time.Minute +// SessionObservationFreshness is the maximum age for an active-session count +// to be trusted. kouja stores observations at its reconcile interval, so a +// stale ACTIVE value can remain after QEMU has already logged out. QEMU and +// the target still enforce the real session state at login. +const SessionObservationFreshness = 10 * time.Second + // Preparer fetches the live export and credential for a VM start. type Preparer struct { API API @@ -126,15 +132,16 @@ func (p *Preparer) validateLiveExport(cfg Config, protocol blockdev.Protocol, di return ErrStaleObservation } } - if export.GetSessionState() == koujav1.SessionState_SESSION_STATE_ACTIVE { - return ErrSessionActive - } activeSessions := int32(0) if observed != nil { activeSessions = observed.GetActiveSessions() } - if (export.GetSessionState() == koujav1.SessionState_SESSION_STATE_UNKNOWN || - export.GetSessionState() == koujav1.SessionState_SESSION_STATE_UNSPECIFIED) && activeSessions > 0 { + sessionFresh := !observeAt.IsZero() && now.Sub(observeAt) <= SessionObservationFreshness + // Session fields in GetVolume come from kouja's last reconciliation. A + // stale ACTIVE value must not wedge a start after QEMU has logged out; + // only a fresh non-zero active-session count is authoritative here. The + // target still refuses a second login if a real session exists. + if sessionFresh && activeSessions > 0 { return ErrSessionActive } if owner := strings.TrimSpace(disk.BackendMetadata.Volume.Owner); owner != "" && diff --git a/internal/kouja/preparer_test.go b/internal/kouja/preparer_test.go index e52e48e..1897aa5 100644 --- a/internal/kouja/preparer_test.go +++ b/internal/kouja/preparer_test.go @@ -138,6 +138,7 @@ func TestPreparerErrors(t *testing.T) { {name: "active session", api: &fakeAPI{getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { value := testGetVolumeResponse(blockdev.ProtocolISCSI, testObserved(now)) value.Export.SessionState = koujav1.SessionState_SESSION_STATE_ACTIVE + value.Observed.ActiveSessions = 1 return value, nil }}, disk: disk, cfg: cfg}, {name: "unknown active session", api: &fakeAPI{getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { @@ -212,3 +213,45 @@ func TestPreparerErrors(t *testing.T) { }) } } + +func TestPreparerAllowsStaleActiveSessionObservation(t *testing.T) { + now := time.Now().UTC() + api := &fakeAPI{ + getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { + observed := testObserved(now.Add(-time.Minute)) + observed.ActiveSessions = 1 + observed.SessionState = koujav1.SessionState_SESSION_STATE_ACTIVE + value := testGetVolumeResponse(blockdev.ProtocolISCSI, observed) + value.Export.SessionState = koujav1.SessionState_SESSION_STATE_ACTIVE + return value, nil + }, + getCredential: func(context.Context, *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { + return &koujav1.GetCredentialResponse{Protocol: koujav1.Protocol_PROTOCOL_ISCSI, Credential: "secret"}, nil + }, + } + preparer := &Preparer{API: api, Now: func() time.Time { return now }} + if _, err := preparer.Prepare(context.Background(), provisionerConfig(blockdev.ProtocolISCSI), testDisk(blockdev.ProtocolISCSI)); err != nil { + t.Fatalf("Prepare(stale active) = %v", err) + } +} + +func TestPreparerAllowsActiveStateWithZeroSessions(t *testing.T) { + now := time.Now().UTC() + api := &fakeAPI{ + getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { + observed := testObserved(now) + observed.ActiveSessions = 0 + observed.SessionState = koujav1.SessionState_SESSION_STATE_NONE + value := testGetVolumeResponse(blockdev.ProtocolISCSI, observed) + value.Export.SessionState = koujav1.SessionState_SESSION_STATE_ACTIVE + return value, nil + }, + getCredential: func(context.Context, *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { + return &koujav1.GetCredentialResponse{Protocol: koujav1.Protocol_PROTOCOL_ISCSI, Credential: "secret"}, nil + }, + } + preparer := &Preparer{API: api, Now: func() time.Time { return now }} + if _, err := preparer.Prepare(context.Background(), provisionerConfig(blockdev.ProtocolISCSI), testDisk(blockdev.ProtocolISCSI)); err != nil { + t.Fatalf("Prepare(active state, zero sessions) = %v", err) + } +} diff --git a/internal/qemu/blockstats.go b/internal/qemu/blockstats.go new file mode 100644 index 0000000..8636ecc --- /dev/null +++ b/internal/qemu/blockstats.go @@ -0,0 +1,134 @@ +package qemu + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "os" + "strings" + "time" +) + +// BlockDeviceStats reports cumulative QEMU block-layer counters for one drive. +type BlockDeviceStats struct { + Device string + ReadBytes uint64 + WriteBytes uint64 + ReadOperations uint64 + WriteOperations uint64 + FailedReadOps uint64 + FailedWriteOps uint64 + InvalidReadOps uint64 + InvalidWriteOps uint64 +} + +type qmpStats struct { + ReadBytes uint64 `json:"rd_bytes"` + WriteBytes uint64 `json:"wr_bytes"` + ReadOperations uint64 `json:"rd_operations"` + WriteOperations uint64 `json:"wr_operations"` + FailedReadOps uint64 `json:"failed_rd_operations"` + FailedWriteOps uint64 `json:"failed_wr_operations"` + InvalidReadOps uint64 `json:"invalid_rd_operations"` + InvalidWriteOps uint64 `json:"invalid_wr_operations"` +} + +type qmpBlockStats struct { + Device string `json:"device"` + NodeName string `json:"node-name"` + Stats *qmpStats `json:"stats"` +} + +// BlockStats returns per-drive QEMU block statistics from QMP. +func (q *QEMU) BlockStats(ctx context.Context, vmID string) ([]BlockDeviceStats, error) { + if !q.Running(vmID) { + return nil, nil + } + vnc, _, _, err := q.paths(vmID) + if err != nil { + return nil, err + } + socket := qmpPathFromSocket(vnc) + dialer := net.Dialer{Timeout: monitorTimeout} + conn, err := dialer.DialContext(ctx, "unix", socket) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %v", ErrMonitorUnavailable, err) + } + return nil, fmt.Errorf("connect qemu qmp: %w", err) + } + defer conn.Close() + deadline := time.Now().Add(monitorTimeout) + if deadlineAt, ok := ctx.Deadline(); ok && deadlineAt.Before(deadline) { + deadline = deadlineAt + } + _ = conn.SetDeadline(deadline) + reader := bufio.NewReader(conn) + // QMP sends a greeting before the first command. + if _, err := reader.ReadString('\n'); err != nil { + return nil, fmt.Errorf("read qemu qmp greeting: %w", err) + } + if err := qmpCommand(conn, reader, "qmp_capabilities", nil); err != nil { + return nil, err + } + var entries []qmpBlockStats + if err := qmpCommand(conn, reader, "query-blockstats", &entries); err != nil { + return nil, err + } + stats := make([]BlockDeviceStats, 0, len(entries)) + for _, entry := range entries { + if entry.Stats == nil { + continue + } + device := strings.TrimSpace(entry.Device) + if device == "" { + device = strings.TrimSpace(entry.NodeName) + } + stats = append(stats, BlockDeviceStats{ + Device: device, + ReadBytes: entry.Stats.ReadBytes, WriteBytes: entry.Stats.WriteBytes, + ReadOperations: entry.Stats.ReadOperations, WriteOperations: entry.Stats.WriteOperations, + FailedReadOps: entry.Stats.FailedReadOps, FailedWriteOps: entry.Stats.FailedWriteOps, + InvalidReadOps: entry.Stats.InvalidReadOps, InvalidWriteOps: entry.Stats.InvalidWriteOps, + }) + } + return stats, nil +} + +func qmpCommand(conn net.Conn, reader *bufio.Reader, command string, result any) error { + payload := map[string]any{"execute": command} + data, err := json.Marshal(payload) + if err != nil { + return err + } + if _, err := conn.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write qemu qmp command: %w", err) + } + for { + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("read qemu qmp response: %w", err) + } + var envelope struct { + Return json.RawMessage `json:"return"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(line), &envelope); err != nil { + continue + } + if len(envelope.Error) > 0 { + return fmt.Errorf("qemu qmp %s: %s", command, string(envelope.Error)) + } + if len(envelope.Return) == 0 { + continue + } + if result != nil { + if err := json.Unmarshal(envelope.Return, result); err != nil { + return fmt.Errorf("decode qemu qmp %s: %w", command, err) + } + } + return nil + } +} diff --git a/internal/qemu/plan_test.go b/internal/qemu/plan_test.go index 3488f12..9439f90 100644 --- a/internal/qemu/plan_test.go +++ b/internal/qemu/plan_test.go @@ -428,3 +428,103 @@ func TestRunStateMonitorErrors(t *testing.T) { t.Fatalf("monitorQuery(missing monitor) = %v", err) } } + +func TestBuildDiskArgsThrottling(t *testing.T) { + local, err := buildDiskArgs(store.VMDisk{ + Device: "/dev/vg0/data", ReadBPS: 10 * 1024 * 1024, WriteBPS: 5 * 1024 * 1024, + }, StartPlan{}, nil) + if err != nil { + t.Fatal(err) + } + localArgs := strings.Join(local, " ") + if !strings.Contains(localArgs, "throttling.bps-read=10485760") || + !strings.Contains(localArgs, "throttling.bps-write=5242880") { + t.Fatalf("local args = %q", localArgs) + } + disk := testRemoteDisk() + disk.ReadBPS = 10 * 1024 * 1024 + disk.WriteBPS = 5 * 1024 * 1024 + remote, err := buildDiskArgs(disk, testISCSIPlan(), map[int64]string{7: "/run/rookery/secret"}) + if err != nil { + t.Fatal(err) + } + remoteArgs := strings.Join(remote, " ") + if !strings.Contains(remoteArgs, "throttling.bps-read=10485760") || + !strings.Contains(remoteArgs, "throttling.bps-write=5242880") { + t.Fatalf("remote args = %q", remoteArgs) + } +} + +func TestBlockStats(t *testing.T) { + ctx := context.Background() + q, err := New(DefaultBinary, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if stats, err := q.BlockStats(ctx, uuid.NewString()); err != nil || stats != nil { + t.Fatalf("BlockStats(stopped) = %+v, %v", stats, err) + } + vmID := uuid.NewString() + startMonitorProcess(t, q, vmID) + socket := qmpPathFromSocket(q.VNCSocket(vmID)) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + _, _ = conn.Write([]byte(`{"QMP":{"version":{"qemu":{"major":10,"minor":0,"micro":0}}}}` + "\n")) + for _, response := range []string{ + `{"return":{}}`, + `{"return":[{"device":"rookery-disk-7","stats":{"rd_bytes":1024,"wr_bytes":2048,"rd_operations":10,"wr_operations":20,"failed_rd_operations":1,"failed_wr_operations":2,"invalid_rd_operations":3,"invalid_wr_operations":4}},{"node-name":"node-x","stats":{"rd_bytes":1}},{"device":"no-stats"}]}`, + } { + if _, err := reader.ReadString('\n'); err != nil { + return + } + _, _ = conn.Write([]byte(response + "\n")) + } + }() + stats, err := q.BlockStats(ctx, vmID) + if err != nil { + t.Fatal(err) + } + if len(stats) != 2 || stats[0].Device != "rookery-disk-7" || stats[0].ReadBytes != 1024 || + stats[0].FailedReadOps != 1 || stats[0].InvalidWriteOps != 4 || stats[1].Device != "node-x" { + t.Fatalf("stats = %+v", stats) + } +} + +func TestBlockStatsErrors(t *testing.T) { + ctx := context.Background() + q, err := New(DefaultBinary, t.TempDir()) + if err != nil { + t.Fatal(err) + } + vmID := uuid.NewString() + startMonitorProcess(t, q, vmID) + if _, err := q.BlockStats(ctx, vmID); err == nil { + t.Fatal("BlockStats accepted a missing QMP socket") + } + socket := qmpPathFromSocket(q.VNCSocket(vmID)) + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + }() + if _, err := q.BlockStats(ctx, vmID); err == nil { + t.Fatal("BlockStats accepted a closed QMP socket") + } +} diff --git a/internal/qemu/qemu.go b/internal/qemu/qemu.go index 930cf05..c151fa3 100644 --- a/internal/qemu/qemu.go +++ b/internal/qemu/qemu.go @@ -151,6 +151,7 @@ func (q *QEMU) StartWithPlan(ctx context.Context, vm store.VM, plan StartPlan) e _ = os.Remove(socket) _ = os.Remove(pidfile) _ = os.Remove(monitor) + _ = os.Remove(qmpPathFromSocket(socket)) placement, err := q.cgroupPlacement(vm) if err != nil { @@ -528,6 +529,11 @@ func (q *QEMU) cleanup(socket, pidfile, monitor string) { _ = os.Remove(socket) _ = os.Remove(pidfile) _ = os.Remove(monitor) + _ = os.Remove(qmpPathFromSocket(socket)) +} + +func qmpPathFromSocket(socket string) string { + return strings.TrimSuffix(socket, ".vnc") + ".qmp" } func (q *QEMU) prepareSecrets(vm store.VM, plan StartPlan) (map[int64]string, error) { @@ -840,6 +846,7 @@ func buildArgsWithPlan(vm store.VM, socket, pidfile, monitor string, plan StartP "-vnc", "unix:"+socket, "-pidfile", pidfile, "-monitor", "unix:"+monitor+",server,nowait", + "-qmp", "unix:"+qmpPathFromSocket(socket)+",server,nowait", "-nic", "none", "-daemonize", ) @@ -873,7 +880,7 @@ func buildDiskArgs(disk store.VMDisk, plan StartPlan, secretPaths map[int64]stri protocol := blockdev.Protocol(disk.Protocol).Normalize() if disk.Source != store.DiskSourceKouja && disk.Source != store.DiskSourceCeph && (disk.Protocol == "" || protocol == blockdev.ProtocolLocal) { - return []string{"-drive", fmt.Sprintf("file=%s,format=raw,if=virtio,cache=none,aio=native", disk.Device)}, nil + return []string{"-drive", fmt.Sprintf("file=%s,format=raw,if=virtio,id=rookery-disk-%d,cache=none,aio=native%s", disk.Device, disk.ID, diskThrottleArgs(disk))}, nil } attachment, ok := plan.Attachments[disk.ID] if !ok { @@ -891,9 +898,9 @@ func buildDiskArgs(disk store.VMDisk, plan StartPlan, secretPaths map[int64]stri secretID := fmt.Sprintf("rookery-iscsi-%d", disk.ID) return []string{ "-object", fmt.Sprintf("secret,id=%s,file=%s,format=raw", secretID, secretPath), - "-drive", fmt.Sprintf("file.driver=iscsi,file.transport=tcp,file.portal=%s,file.target=%s,file.lun=%d,file.user=%s,file.password-secret=%s,file.initiator-name=%s,file.timeout=30,if=virtio,format=raw,cache=none,werror=stop,rerror=stop", + "-drive", fmt.Sprintf("file.driver=iscsi,file.transport=tcp,file.portal=%s,file.target=%s,file.lun=%d,file.user=%s,file.password-secret=%s,file.initiator-name=%s,file.timeout=30,if=virtio,id=rookery-disk-%d,format=raw,cache=none,werror=stop,rerror=stop%s", attachment.ISCSI.Portal, attachment.ISCSI.Target, attachment.ISCSI.LUN, - attachment.ISCSI.Username, secretID, attachment.ISCSI.Initiator), + attachment.ISCSI.Username, secretID, attachment.ISCSI.Initiator, disk.ID, diskThrottleArgs(disk)), }, nil case blockdev.ProtocolNVMeTCP: return nil, fmt.Errorf("NVMe/TCP attachments are not implemented") @@ -903,3 +910,14 @@ func buildDiskArgs(disk store.VMDisk, plan StartPlan, secretPaths map[int64]stri return nil, fmt.Errorf("unknown attachment protocol %q", attachment.Protocol) } } + +func diskThrottleArgs(disk store.VMDisk) string { + var args string + if disk.ReadBPS > 0 { + args += fmt.Sprintf(",throttling.bps-read=%d", disk.ReadBPS) + } + if disk.WriteBPS > 0 { + args += fmt.Sprintf(",throttling.bps-write=%d", disk.WriteBPS) + } + return args +} diff --git a/internal/qemu/qemu_test.go b/internal/qemu/qemu_test.go index e59df37..f53ccad 100644 --- a/internal/qemu/qemu_test.go +++ b/internal/qemu/qemu_test.go @@ -48,7 +48,7 @@ func TestBuildArgsWithBootMedia(t *testing.T) { "-chardev qemu-vdagent,id=vdagent,clipboard=on,name=vdagent", "-device virtio-serial-pci", "-device virtserialport,chardev=vdagent,name=com.redhat.spice.0", - "-drive file=/dev/vg0/web-01-root,format=raw,if=virtio,cache=none,aio=native", + "-drive file=/dev/vg0/web-01-root,format=raw,if=virtio,id=rookery-disk-0,cache=none,aio=native", "-vnc unix:" + socket, "-pidfile " + pidfile, "-monitor unix:" + monitor + ",server,nowait", @@ -89,8 +89,8 @@ func TestBuildArgsWithMultipleDisks(t *testing.T) { args := strings.Join(buildArgs(vm, "/tmp/vm.vnc", "/tmp/vm.pid", "/tmp/vm.monitor"), " ") for _, want := range []string{ - "-drive file=/dev/vg0/web-01-root,format=raw,if=virtio,cache=none,aio=native", - "-drive file=/dev/vg0/web-01-data,format=raw,if=virtio,cache=none,aio=native", + "-drive file=/dev/vg0/web-01-root,format=raw,if=virtio,id=rookery-disk-0,cache=none,aio=native", + "-drive file=/dev/vg0/web-01-data,format=raw,if=virtio,id=rookery-disk-0,cache=none,aio=native", } { if !strings.Contains(args, want) { t.Fatalf("args %q do not contain %q", args, want) diff --git a/internal/storage/kouja.go b/internal/storage/kouja.go index 48831ca..d463c92 100644 --- a/internal/storage/kouja.go +++ b/internal/storage/kouja.go @@ -2,8 +2,10 @@ package storage import ( "context" + "errors" "fmt" "strings" + "time" koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" @@ -14,12 +16,23 @@ import ( // KoujaDriver provisions disks through a kouja agent. type KoujaDriver struct { - newClient func(kouja.Config) (*kouja.Client, error) + newClient func(kouja.Config) (*kouja.Client, error) + sessionWait time.Duration + sessionPoll time.Duration } +const ( + defaultKoujaSessionWait = 2 * time.Second + defaultKoujaSessionPoll = 250 * time.Millisecond +) + // NewKoujaDriver creates a kouja driver. func NewKoujaDriver() *KoujaDriver { - return &KoujaDriver{newClient: kouja.NewClient} + return &KoujaDriver{ + newClient: kouja.NewClient, + sessionWait: defaultKoujaSessionWait, + sessionPoll: defaultKoujaSessionPoll, + } } // Kind identifies the kouja driver. @@ -140,7 +153,29 @@ func (d *KoujaDriver) PrepareStart(ctx context.Context, backend store.StorageBac } defer client.Close() preparer := &kouja.Preparer{API: client} - return preparer.Prepare(ctx, cfg, disk) + wait := d.sessionWait + if wait <= 0 { + wait = defaultKoujaSessionWait + } + poll := d.sessionPoll + if poll <= 0 { + poll = defaultKoujaSessionPoll + } + deadline := time.Now().Add(wait) + for { + attachment, err := preparer.Prepare(ctx, cfg, disk) + if !errors.Is(err, kouja.ErrSessionActive) { + return attachment, err + } + if !time.Now().Before(deadline) { + return blockdev.Attachment{}, fmt.Errorf("%w after waiting %s; disconnect the other iSCSI initiator or wait for the session to expire", err, wait) + } + select { + case <-ctx.Done(): + return blockdev.Attachment{}, fmt.Errorf("%w: %v", err, ctx.Err()) + case <-time.After(poll): + } + } } // Health checks the kouja endpoint and optionally an existing disk. diff --git a/internal/storage/kouja_test.go b/internal/storage/kouja_test.go index 2740d64..19eb638 100644 --- a/internal/storage/kouja_test.go +++ b/internal/storage/kouja_test.go @@ -12,11 +12,13 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "time" koujav1 "github.com/voidstarit/kouja/gen/kouja/v1" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/voidstarit/rookery/internal/blockdev" "github.com/voidstarit/rookery/internal/kouja" @@ -310,3 +312,59 @@ func TestKoujaDriverErrorBranches(t *testing.T) { t.Fatal("Health accepted client error") } } + +func TestKoujaDriverWaitsForSession(t *testing.T) { + ctx := context.Background() + calls := 0 + api := &fakeAPI{ + getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { + calls++ + state := koujav1.SessionState_SESSION_STATE_ACTIVE + if calls > 1 { + state = koujav1.SessionState_SESSION_STATE_NONE + } + active := int32(0) + if state == koujav1.SessionState_SESSION_STATE_ACTIVE { + active = 1 + } + return &koujav1.GetVolumeResponse{ + Desired: &koujav1.Volume{Owner: "spiffe://kouja/caller/rookery", State: koujav1.VolumeState_VOLUME_STATE_EXPORTED}, + Observed: &koujav1.Observation{TargetPresent: true, AclPresent: true, ActiveSessions: active, ObservedAt: timestamppb.Now()}, + Export: &koujav1.Export{Protocol: koujav1.Protocol_PROTOCOL_ISCSI, TargetName: "iqn.2026-11.io.example:disk", + Portal: "10.0.0.1:3260", Lun: 0, State: koujav1.ExportState_EXPORT_STATE_EXPORTED, SessionState: state}, + }, nil + }, + getCredential: func(context.Context, *koujav1.GetCredentialRequest) (*koujav1.GetCredentialResponse, error) { + return &koujav1.GetCredentialResponse{Protocol: koujav1.Protocol_PROTOCOL_ISCSI, Credential: "secret"}, nil + }, + } + driver := storageKoujaDriver(api) + driver.sessionWait = time.Second + driver.sessionPoll = time.Millisecond + disk := store.Disk{Protocol: string(blockdev.ProtocolISCSI), BackendMetadata: store.DiskBackendMetadata{ + Volume: store.VolumeMetadata{VolumeID: "volume-1", ResourceID: "resource-1", Owner: "spiffe://kouja/caller/rookery"}, + Attachment: storageReference(), + }} + attachment, err := driver.PrepareStart(ctx, storageKoujaBackend(t), disk) + if err != nil || attachment.ISCSI == nil || calls < 2 { + t.Fatalf("PrepareStart() = %+v, %v, calls=%d", attachment, err, calls) + } + + alwaysActive := &fakeAPI{ + getVolume: func(context.Context, *koujav1.GetVolumeRequest) (*koujav1.GetVolumeResponse, error) { + return &koujav1.GetVolumeResponse{ + Desired: &koujav1.Volume{Owner: "spiffe://kouja/caller/rookery", State: koujav1.VolumeState_VOLUME_STATE_EXPORTED}, + Observed: &koujav1.Observation{TargetPresent: true, AclPresent: true, ActiveSessions: 1, ObservedAt: timestamppb.Now()}, + Export: &koujav1.Export{Protocol: koujav1.Protocol_PROTOCOL_ISCSI, TargetName: "iqn.2026-11.io.example:disk", + Portal: "10.0.0.1:3260", Lun: 0, State: koujav1.ExportState_EXPORT_STATE_EXPORTED, + SessionState: koujav1.SessionState_SESSION_STATE_ACTIVE}, + }, nil + }, + } + driver = storageKoujaDriver(alwaysActive) + driver.sessionWait = 5 * time.Millisecond + driver.sessionPoll = time.Millisecond + if _, err := driver.PrepareStart(ctx, storageKoujaBackend(t), disk); err == nil || !strings.Contains(err.Error(), "after waiting") { + t.Fatalf("PrepareStart(active) = %v", err) + } +} diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go index 32fdb74..054e479 100644 --- a/internal/store/migration_test.go +++ b/internal/store/migration_test.go @@ -344,8 +344,8 @@ func TestMigrationPreservesLegacyVMsWithoutDisks(t *testing.T) { if err := st.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { t.Fatalf("read schema version: %v", err) } - if version != 49 { - t.Fatalf("schema version = %d, want 49", version) + if version != 50 { + t.Fatalf("schema version = %d, want 50", version) } } @@ -643,7 +643,7 @@ func dropDiskSourceSchema(t *testing.T, db *sql.DB) { if _, err := db.Exec(`DROP INDEX IF EXISTS idx_disks_host_device`); err != nil { t.Fatalf("drop host device index: %v", err) } - for _, column := range []string{"source", "device", "size_bytes"} { + for _, column := range []string{"source", "device", "size_bytes", "read_bps", "write_bps"} { if _, err := db.Exec(`ALTER TABLE disks DROP COLUMN ` + column); err != nil { t.Fatalf("drop disks.%s: %v", column, err) } diff --git a/internal/store/migrations/0050_disk_bandwidth.sql b/internal/store/migrations/0050_disk_bandwidth.sql new file mode 100644 index 0000000..59a3b3b --- /dev/null +++ b/internal/store/migrations/0050_disk_bandwidth.sql @@ -0,0 +1,2 @@ +ALTER TABLE disks ADD COLUMN read_bps INTEGER NOT NULL DEFAULT 0; +ALTER TABLE disks ADD COLUMN write_bps INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/storage_backends.go b/internal/store/storage_backends.go index ffee4bf..b9a6f3a 100644 --- a/internal/store/storage_backends.go +++ b/internal/store/storage_backends.go @@ -27,9 +27,12 @@ type KoujaConfig struct { StorageClass string `json:"storage_class"` Initiator string `json:"initiator,omitempty"` HostNQN string `json:"host_nqn,omitempty"` - CAFile string `json:"ca_file"` - ClientCertFile string `json:"client_cert_file"` - ClientKeyFile string `json:"client_key_file"` + CAFile string `json:"ca_file,omitempty"` + ClientCertFile string `json:"client_cert_file,omitempty"` + ClientKeyFile string `json:"client_key_file,omitempty"` + CAPEM string `json:"ca_pem,omitempty"` + ClientCertPEM string `json:"client_cert_pem,omitempty"` + ClientKeyPEM string `json:"client_key_pem,omitempty"` ServerName string `json:"server_name,omitempty"` Portal string `json:"portal,omitempty"` } diff --git a/internal/store/store.go b/internal/store/store.go index 66a9f04..eec6b35 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -187,6 +187,8 @@ type VMDisk struct { Device string `json:"device"` Source string `json:"source"` Protocol string `json:"protocol"` + ReadBPS int64 `json:"read_bps"` + WriteBPS int64 `json:"write_bps"` } // VMMedia is the boot media summary returned with a VM. @@ -457,6 +459,8 @@ type Disk struct { Device string `json:"device"` Source string `json:"source"` Protocol string `json:"protocol"` + ReadBPS int64 `json:"read_bps"` + WriteBPS int64 `json:"write_bps"` BackendMetadata DiskBackendMetadata `json:"backend_metadata,omitempty"` IsTemplate bool `json:"is_template"` TemplateDiskID *int64 `json:"template_disk_id,omitempty"` @@ -940,6 +944,9 @@ func (s *Store) DeleteVM(ctx context.Context, id string) error { // CreateDisk inserts a disk record and returns the stored record. func (s *Store) CreateDisk(ctx context.Context, disk Disk) (Disk, error) { + if disk.ReadBPS < 0 || disk.WriteBPS < 0 { + return Disk{}, errors.New("disk bandwidth limits must not be negative") + } if disk.Source == "" { disk.Source = DiskSourceLVMThin } @@ -1014,13 +1021,13 @@ func (s *Store) CreateDisk(ctx context.Context, disk Disk) (Disk, error) { res, err := s.db.ExecContext(ctx, ` INSERT INTO disks (name, size_gb, vg, thin_pool, storage_backend_id, source, device, size_bytes, - attachment_protocol, backend_metadata, + attachment_protocol, backend_metadata, read_bps, write_bps, deletion_state, deletion_error, deletion_attempts, deletion_next_attempt_at, deletion_claimed_at, is_template, template_disk_id, provisioning_script, provisioning_timeout, prepared_by_script, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, disk.Name, disk.SizeGB, disk.VG, disk.ThinPool, disk.StorageBackendID, disk.Source, - disk.Device, disk.SizeBytes, disk.Protocol, backendMetadata, + disk.Device, disk.SizeBytes, disk.Protocol, backendMetadata, disk.ReadBPS, disk.WriteBPS, disk.DeletionState, disk.DeletionError, disk.DeletionAttempts, nextAttempt, claimedAt, disk.IsTemplate, disk.TemplateDiskID, disk.ProvisioningScript, disk.ProvisioningTimeout, disk.PreparedByScript, now, now, @@ -1190,6 +1197,28 @@ func (s *Store) SetDiskProvisioning(ctx context.Context, id int64, script, timeo return s.GetDisk(ctx, id) } +// SetDiskBandwidth stores per-disk QEMU block throttling limits. Zero means +// unlimited. Limits are applied when the disk is next attached to a VM start. +func (s *Store) SetDiskBandwidth(ctx context.Context, id int64, readBPS, writeBPS int64) (Disk, error) { + if readBPS < 0 || writeBPS < 0 { + return Disk{}, errors.New("disk bandwidth limits must not be negative") + } + res, err := s.db.ExecContext(ctx, ` + UPDATE disks SET read_bps = ?, write_bps = ?, updated_at = ? WHERE id = ?`, + readBPS, writeBPS, time.Now().UTC().UnixMilli(), id) + if err != nil { + return Disk{}, fmt.Errorf("update disk bandwidth: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return Disk{}, fmt.Errorf("read updated disk rows: %w", err) + } + if affected == 0 { + return Disk{}, ErrDiskNotFound + } + return s.GetDisk(ctx, id) +} + // CountDiskClones returns the number of disks created from a template. func (s *Store) CountDiskClones(ctx context.Context, id int64) (int, error) { var count int @@ -2160,7 +2189,7 @@ func (s *Store) loadVMDisks(ctx context.Context, vms []VM) error { rows, err := s.db.QueryContext(ctx, ` SELECT vd.vm_id, d.id, d.name, d.size_gb, d.vg, d.thin_pool, d.storage_backend_id, sb.name, d.source, d.device, d.size_bytes, - d.attachment_protocol + d.attachment_protocol, d.read_bps, d.write_bps FROM vm_disks AS vd JOIN disks AS d ON d.id = vd.disk_id LEFT JOIN storage_backends AS sb ON sb.id = d.storage_backend_id @@ -2182,7 +2211,8 @@ func (s *Store) loadVMDisks(ctx context.Context, vms []VM) error { sizeBytes int64 ) if err := rows.Scan(&vmID, &disk.ID, &disk.Name, &disk.SizeGB, &vg, &pool, - &backend, &backendName, &disk.Source, &device, &sizeBytes, &disk.Protocol); err != nil { + &backend, &backendName, &disk.Source, &device, &sizeBytes, &disk.Protocol, + &disk.ReadBPS, &disk.WriteBPS); err != nil { return err } disk.VG = vg @@ -2457,7 +2487,7 @@ const vmSelect = ` const diskSelect = ` SELECT d.id, d.name, d.size_gb, d.vg, d.thin_pool, d.storage_backend_id, sb.name, d.source, d.device, d.size_bytes, - d.attachment_protocol, d.backend_metadata, + d.attachment_protocol, d.backend_metadata, d.read_bps, d.write_bps, d.deletion_state, d.deletion_error, d.deletion_attempts, d.deletion_next_attempt_at, d.deletion_claimed_at, d.is_template, d.template_disk_id, @@ -2576,6 +2606,8 @@ func scanDisk(s scanner) (Disk, error) { backend sql.NullString sizeBytes int64 backendMetadata string + readBPS int64 + writeBPS int64 deletionNextAttemptAt int64 deletionClaimedAt int64 isTemplate int64 @@ -2602,6 +2634,8 @@ func scanDisk(s scanner) (Disk, error) { &sizeBytes, &disk.Protocol, &backendMetadata, + &readBPS, + &writeBPS, &disk.DeletionState, &disk.DeletionError, &disk.DeletionAttempts, @@ -2622,6 +2656,8 @@ func scanDisk(s scanner) (Disk, error) { } disk.SizeBytes = uint64(sizeBytes) + disk.ReadBPS = readBPS + disk.WriteBPS = writeBPS if disk.Source == "" { disk.Source = DiskSourceLVMThin } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4fb604b..02e5278 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1043,3 +1043,41 @@ func TestDiskProvisioningSettings(t *testing.T) { t.Fatalf("updated disk provisioning = %+v", updated) } } + +func TestDiskBandwidthLimits(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + disk, err := st.CreateDisk(ctx, store.Disk{ + Name: "limited", SizeGB: 5, VG: "vg0", ThinPool: "pool", + ReadBPS: 10 * 1024 * 1024, WriteBPS: 5 * 1024 * 1024, + }) + if err != nil { + t.Fatal(err) + } + if disk.ReadBPS != 10*1024*1024 || disk.WriteBPS != 5*1024*1024 { + t.Fatalf("created disk = %+v", disk) + } + updated, err := st.SetDiskBandwidth(ctx, disk.ID, 3*1024*1024, 0) + if err != nil { + t.Fatal(err) + } + if updated.ReadBPS != 3*1024*1024 || updated.WriteBPS != 0 { + t.Fatalf("updated disk = %+v", updated) + } + if _, err := st.SetDiskBandwidth(ctx, disk.ID, -1, 0); err == nil { + t.Fatal("SetDiskBandwidth accepted a negative limit") + } + if _, err := st.CreateDisk(ctx, store.Disk{Name: "negative", SizeGB: 5, ReadBPS: -1}); err == nil { + t.Fatal("CreateDisk accepted a negative bandwidth limit") + } + if _, err := st.SetDiskBandwidth(ctx, 999999, 1, 1); !errors.Is(err, store.ErrDiskNotFound) { + t.Fatalf("SetDiskBandwidth(missing) = %v", err) + } + vm, err := st.CreateVM(ctx, store.VM{Name: "limited-vm", VCPUs: 1, MemoryMB: 512, DiskIDs: []int64{disk.ID}}) + if err != nil { + t.Fatal(err) + } + if len(vm.Disks) != 1 || vm.Disks[0].ReadBPS != 3*1024*1024 || vm.Disks[0].WriteBPS != 0 { + t.Fatalf("vm disks = %+v", vm.Disks) + } +} diff --git a/internal/webui/static/build/app.js b/internal/webui/static/build/app.js index 4d5532e..ecda63c 100644 --- a/internal/webui/static/build/app.js +++ b/internal/webui/static/build/app.js @@ -1,52 +1,60 @@ -var Pe,ns,$o,at,Qn,Et,ss,Tt,as,tn,en,Ro,Lt={},It=[],Fo=/^m(i|n|o|s|text|space)$/,nn=Array.isArray,sn=It.slice,lt=Object.assign;function At(t){t&&t.parentNode&&t.remove()}function os(t,n,s){var l,a,o,f={},r=arguments.length;for(o in n)o=="key"?l=n[o]:o=="ref"&&typeof t!="function"?a=n[o]:f[o]=n[o];return r>2&&(f.children=r>3?sn.call(arguments,2):s),ht(t,f,l,a,null)}function ht(t,n,s,l,a){var o={type:t,props:n,key:s,ref:l,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:a||++ns,__i:-1,__u:0};return!a&&Pe.vnode&&Pe.vnode(o),o}function ge(t){return t.children}function Pt(t,n){this.props=t,this.context=n,this.__g=0}function ot(t,n){if(n==null)return t.__?ot(t.__,t.__i+1):null;for(var s;nn&&at.sort(ss),t=at.shift(),n=at.length,8&t.__g&&(l=void 0,a=void 0,o=(a=(s=t).__v).__e,f=[],r=[],(b=s.__P)&&((l=lt({constructor:void 0},a)).__v=a.__v+1,Pe.vnode&&Pe.vnode(l),an(b,l,a,s.__n,b.namespaceURI,32&a.__u?[o]:null,f,o||ot(a),32&a.__u,r),l.__v=a.__v,l.__.__k[l.__i]=l,cs(f,l,r),a.__=a.__e=null,l.__e!=o&&is(l)))}finally{at.length=Et=0}}function rs(t,n,s,l,a,o,f,r,b,w,u){var k,h,A,I,m,g,P=l.__k||It,L=n.length;for(b=Ho(s,n,P,b,L),k=0;kb?P--:P++,L=!0)):(~w||(a>m?P--:a>1](u?1:0)){for(a=s-1,o=s+1;a>=0||o=0?a--:o++])&&!(2&w.__u)&&r==w.key&&b==w.type)return f}return-1}function ts(t,n,s){s==null&&(s=""),n[0]=="-"?t.setProperty(n,s):t[n]=s}function St(t,n,s,l,a){var o,f;e:if(n=="style")if(typeof s=="string")t.style.cssText=s;else{if(typeof l=="string"&&(t.style.cssText=l=""),l)for(n in l)s&&n in s||ts(t.style,n,"");if(s)for(n in s)l&&s[n]==l[n]||ts(t.style,n,s[n])}else if(n[0]=="o"&&n[1]=="n")(t.__e||(t.__e={}))[n]=s,s&&l||(f=en[n]||(en[n]=Uo(n)),(t.__a||(t.__a={}))[n]=tn,o=n!=(n=n.replace(as,"$1")),(n=n.slice(2))[0]<"a"&&(n=n.toLowerCase()),s?t.addEventListener(n,f,o):t.removeEventListener(n,f,o));else{if(a=="http://www.w3.org/2000/svg")n=n.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(n!="width"&&n!="height"&&n!="href"&&n!="list"&&n!="form"&&n!="tabIndex"&&n!="download"&&n!="rowSpan"&&n!="colSpan"&&n!="role"&&n!="popover"&&n in t)try{t[n]=s??"";break e}catch{}typeof s=="function"||(s==null||s===!1&&n[4]!="-"?t.removeAttribute(n):t.setAttribute(n,n=="popover"&&s==1?"":s))}}function Uo(t){return function(n){if(this.__e){var s=this.__e[t];if(n[Tt]==null)n[Tt]=tn++;else if(n[Tt]=s.__.length&&s.__.push({}),s.__[t]}function d(t){return Nt=1,zo(ks,t)}function zo(t,n,s){var l=pn(gt++,2);if(l.t=t,!l.__c&&(l.__=[s?s(n):ks(void 0,n),function(o){var f=l.__N?l.__N[0]:l.__[0],r=l.t(f,o);dn(f,r)||(l.__N=[r,l.__[1]],l.__c.setState({}))}],l.__c=Re,!Re.__f)){Re.__f=!0;var a=Re.shouldComponentUpdate;Re.shouldComponentUpdate=function(o,f,r){var b=this.__H;if(!b)return!0;var w=!1,u=this.props!=o;if(b.__.some(function(h){h.__N&&(w=!0,dn(h.__[0],h.__N[0])||(u=!0))}),a){var k=a.call(this,o,f,r);return w?k||u:k}return!w||u}}return l.__}function z(t,n){var s=pn(gt++,3);!Me.__s&&ws(s.__H,n)&&(s.__P=!0,s.__=t,s.u=n,Re.__H.__h.push(s))}function te(t){return Nt=5,We(function(){return{current:t}},[])}function We(t,n){var s=pn(gt++,7);return ws(s.__H,n)&&(s.__=t(),s.__H=n),s.__}function Go(){var t;do{for(;t=cn.shift();)try{bt(t)}catch(s){Me.__e(s,{__:(t=t.__P)&&t.__v})}for(;t=_s.shift();){var n=t.__H;if(t.__P&&n)try{n.__h.some(bt),n.__h.some(un),n.__h=[]}catch(s){n.__h=[],Me.__e(s,t.__v)}}}while(cn.length)}Me.__b=function(t){Re=null,ps&&ps(t)},Me.__=function(t,n){t&&n.__k&&n.__k.__m&&(t.__m=n.__k.__m),gs&&gs(t,n)},Me.__r=function(t){fs&&fs(t),gt=0;var n=(Re=t.__c).__H;n&&(ln==Re?Re.__h=[]:(n.__h.some(bt),n.__h.some(un),gt=0),n.__h=[],n.__.some(function(s){s.__N&&(s.__=s.__N),s.u=s.__N=void 0})),ln=Re},Me.diffed=function(t){ms&&ms(t);var n=t.__c;n&&n.__H&&(n.__H.__h.length&&ys(_s.push(n)),n.__H.__.some(function(s){s.u&&(s.__H=s.u)})),ln=Re=null},Me.__c=function(t,n){n.some(function(s){try{s.__h.some(bt),s.__h=s.__h.filter(function(l){return!l.__||un(l)})}catch(l){n.some(function(a){a.__h&&(a.__h=[])}),n=[],Me.__e(l,s.__v)}}),hs&&hs(t,n)},Me.unmount=function(t){bs&&bs(t);var n,s,l=t.__c;l&&l.__H&&(l.__H.__.some(function(a){try{if(a.__P&&a.__c){if(s===void 0){for(s=t.__;s&&(!s.__c||!s.__c.__P);)s=s.__;s=s&&s.__c}a.__P=s,ys(cn.push(a))}else bt(a)}catch(o){n=o}}),l.__H=void 0,n&&Me.__e(n,l.__v))};var vs=typeof requestAnimationFrame=="function";function Ko(t){var n,s=function(){clearTimeout(l),vs&&cancelAnimationFrame(n),setTimeout(t)},l=setTimeout(s,35);vs&&(n=requestAnimationFrame(s))}function ys(t){t!=1&&us==Me.requestAnimationFrame||((us=Me.requestAnimationFrame)||Ko)(Go)}function bt(t){var n=Re,s=t.__c;typeof s=="function"&&(t.__c=void 0,s()),Re=n}function un(t){var n=Re;t.__c=t.__(),Re=n}function ws(t,n){return!t||t.length!=n.length||n.some(function(s,l){return!dn(s,t[l])})}function ks(t,n){return typeof n=="function"?n(t):n}var Jo=0;function e(t,n,s,l,a,o){n||(n={});var f,r,b=n;if("ref"in b&&typeof t!="function")for(r in b={},n)r=="ref"?f=n[r]:b[r]=n[r];var w={type:t,props:b,key:s,ref:f,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--Jo,__i:-1,__u:0};return(a||o)&&(w.__source=a,w.__self=o),Pe.vnode&&Pe.vnode(w),w}var xt=null,Dt=[],Xo=1;function oe(t,n=!1){let s={id:Xo++,message:t,error:n};xt?xt(s):Dt.push(s)}function Zo(){let[t,n]=d(null),s=te(null);return z(()=>{let l=a=>{s.current!==null&&window.clearTimeout(s.current),n(a),s.current=window.setTimeout(()=>{n(null),s.current=null},3500)};if(xt=l,Dt.length>0){let a=Dt;Dt=[];for(let o of a)l(o)}return()=>{xt=null,s.current!==null&&(window.clearTimeout(s.current),s.current=null)}},[]),t?e("div",{class:t.error?"toast error":"toast",role:"status","aria-live":"polite",children:t.message}):null}function Cs(t){Fe(e(Zo,{}),t)}var Ze=class extends Error{constructor(n,s){super(n),this.name="APIError",this.status=s}},Ls=[1e3,2e3,4e3,1e4],Mt=Ls.length+1,jo=new Set([429,503]),Yo=new Set([408,425,429,500,502,503,504]),Ss=new Set(["GET","HEAD","PUT","DELETE","OPTIONS"]),Vt=new Map;function Is(t){return(t.method||"GET").toUpperCase()}function Ts(){return typeof DOMException=="function"?new DOMException("The request was aborted","AbortError"):Object.assign(new Error("The request was aborted"),{name:"AbortError"})}function Qo(t,n){return new Promise((s,l)=>{if(n?.aborted){l(Ts());return}let a=window.setTimeout(()=>{n?.removeEventListener("abort",o),s()},t),o=()=>{window.clearTimeout(a),l(Ts())};n?.addEventListener("abort",o,{once:!0})})}function ei(t,n,s){return s?.aborted?!1:n.status===void 0?Ss.has(t):jo.has(n.status)?!0:Ss.has(t)&&Yo.has(n.status)}function Ps(t,n,s){let l=s.status===void 0?s.message||"network error":`${s.message||"request failed"} (HTTP ${s.status})`;return new Ze(`${t} ${n} failed after ${Mt} attempts: ${l}`,s.status)}async function ti(t,n){let s=new Headers(n.headers||{});n.body!==void 0&&s.set("Content-Type","application/json");let l;try{l=await fetch(t,{...n,headers:s})}catch(o){throw o instanceof Error&&o.name==="AbortError"?o:new Ze(o instanceof Error?o.message:String(o))}let a=null;if(l.status!==204){let o=await l.text();if(o)try{a=JSON.parse(o)}catch{a=null}}if(!l.ok){let o=`Request failed (${l.status})`;throw a&&typeof a=="object"&&("error"in a&&typeof a.error=="string"?o=a.error:"message"in a&&typeof a.message=="string"&&(o=a.message)),new Ze(o,l.status)}return a}async function Es(t,n){let s=Is(n),l;for(let a=1;a<=Mt;a+=1)try{return await ti(t,n)}catch(o){if(!(o instanceof Ze)||(l=o,!ei(s,o,n.signal)))throw o;if(a>=Mt)throw Ps(s,t,o);let f=Ls[a-1];console.warn(`${s} ${t} failed (attempt ${a}/${Mt}); retrying in ${f/1e3}s: ${o.message}`),await Qo(f,n.signal)}throw Ps(s,t,l??new Ze("request failed"))}function V(t,n={}){if(Is(n)!=="GET"||n.signal)return Es(t,n);let l=`GET ${t}`,a=Vt.get(l);if(a)return a;let o=Es(t,n).finally(()=>{Vt.get(l)===o&&Vt.delete(l)});return Vt.set(l,o),o}function ni(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function fn(t,n){let s=new Blob([n],{type:"application/x-pem-file"}),l=URL.createObjectURL(s),a=document.createElement("a");a.href=l,a.download=t,a.click(),URL.revokeObjectURL(l)}function As(){let t=te(null),[n,s]=d(!1),[l,a]=d(!0),[o,f]=d([]),[r,b]=d(""),[w,u]=d("365"),[k,h]=d(null),[A,I]=d(""),[m,g]=d(!1),[P,L]=d(!1);z(()=>{if(!n){t.current?.close();return}t.current?.showModal(),h(null),I(""),S()},[n]);let S=async()=>{g(!0);try{let T=await fetch("/api/pki/clients",{headers:{Accept:"application/json"}}),H=await T.text(),G=null;if(H)try{G=JSON.parse(H)}catch{G=null}if(!T.ok){let J=G&&typeof G=="object"&&"error"in G&&typeof G.error=="string"?G.error:`Request failed (${T.status})`;throw new Ze(J,T.status)}f(Array.isArray(G)?G:[]),a(!0),I("")}catch(T){T instanceof Ze&&T.status===503?(a(!1),I("mTLS is not enabled on this server. Start rookery with -mtls-addr and -pki-dir.")):I(T instanceof Error?T.message:String(T))}finally{g(!1)}},N=async T=>{if(T.preventDefault(),!(P||!r.trim())){L(!0),I("");try{let H=await V("/api/pki/clients",{method:"POST",body:JSON.stringify({name:r.trim(),days:Number(w)||365})});h(H),b(""),await S(),oe(`Issued client certificate for ${H.name}`)}catch(H){I(H instanceof Error?H.message:String(H))}finally{L(!1)}}},p=async T=>{if(window.confirm(`Revoke the certificate for ${T.name}? Existing connections will fail on the next handshake.`)){L(!0),I("");try{await V(`/api/pki/clients/${T.id}`,{method:"DELETE"}),await S(),oe(`Revoked client certificate for ${T.name}`)}catch(H){I(H instanceof Error?H.message:String(H))}finally{L(!1)}}};return e("div",{class:"sidebar-settings",children:[e("p",{class:"eyebrow",children:"Access"}),e("button",{class:"tab",type:"button",onClick:()=>s(!0),children:"Client certificates"}),e("span",{class:"field-hint",children:"Public mTLS API access"}),e("dialog",{ref:t,class:"dialog dialog-wide","aria-labelledby":"client-certs-title",onClose:()=>s(!1),onClick:T=>{T.target===T.currentTarget&&s(!1)},onCancel:T=>{P&&T.preventDefault()},children:e("div",{class:"dialog-body",children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"mTLS access"}),e("h2",{id:"client-certs-title",children:"Client certificates"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>s(!1),children:"\xD7"})]}),l?e(ge,{children:[e("p",{class:"field-hint",children:"Clients connect to the public mTLS listener and must present a certificate signed by this server's CA. Private keys are shown once and never stored by Rookery."}),e("div",{class:"actions",children:e("a",{class:"button ghost",href:"/api/pki/ca",download:"rookery-ca.pem",children:"Download CA certificate"})}),e("form",{class:"form-grid",onSubmit:T=>{N(T)},children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Client name"}),e("input",{type:"text",required:!0,maxlength:64,autocomplete:"off",spellcheck:!1,placeholder:"ops-laptop",value:r,onInput:T=>b(T.currentTarget.value)}),e("span",{class:"field-hint",children:"Used as the certificate common name."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Validity (days)"}),e("input",{type:"number",required:!0,min:1,max:3650,value:w,onInput:T=>u(T.currentTarget.value)})]}),e("div",{class:"field field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:P||!r.trim(),children:P?"Issuing\u2026":"Issue certificate"})})]})]}):e("div",{class:"banner",role:"alert",children:A||"mTLS is not enabled on this server."}),k?e("section",{class:"panel panel-spaced","aria-labelledby":"issued-cert-title",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h3",{id:"issued-cert-title",children:["Certificate for ",k.name]}),e("p",{class:"panel-subtitle",children:"Download or copy these files now; the private key will not be shown again."})]})}),e("div",{class:"actions",children:[e("button",{class:"button primary",type:"button",onClick:()=>fn("client.crt",k.certificate_pem),children:"Download client.crt"}),e("button",{class:"button primary",type:"button",onClick:()=>fn("client.key",k.private_key_pem),children:"Download client.key"}),e("button",{class:"button ghost",type:"button",onClick:()=>fn("ca.crt",k.ca_pem),children:"Download ca.crt"})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Client certificate (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:5,value:k.certificate_pem})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Client private key (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:5,value:k.private_key_pem})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CA certificate (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:4,value:k.ca_pem})]})]}):null,A&&l?e("p",{class:"form-error",role:"alert",children:A}):null,l?e("div",{class:"table-wrap",children:m?e("p",{class:"field-hint",children:"Loading issued certificates\u2026"}):o.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Serial"}),e("th",{scope:"col",children:"Expires"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col"})]})}),e("tbody",{children:o.map(T=>e("tr",{children:[e("td",{children:T.name}),e("td",{class:"mono",children:[T.serial.slice(0,16),"\u2026"]}),e("td",{children:ni(T.not_after)}),e("td",{children:e("span",{class:"status-badge","data-status":T.revoked?"disabled":"running",children:T.revoked?"revoked":"valid"})}),e("td",{children:T.revoked?null:e("button",{class:"row-action danger",type:"button",disabled:P,onClick:()=>{p(T)},children:"Revoke"})})]},T.id))})]}):e("p",{class:"field-hint",children:"No client certificates have been issued yet."})}):null,e("footer",{class:"dialog-actions",children:e("button",{class:"button ghost",type:"button",onClick:()=>s(!1),children:"Close"})})]})})]})}var vt="rookery:route-changed";function $t(){let t=window.location.pathname.replace(/\/+$/,"")||"/";return{path:t,segments:t.split("/").filter(Boolean),query:new URLSearchParams(window.location.search),search:window.location.search}}function Q(t,n=!1){let s=t.startsWith("/")?t:`/${t}`;`${window.location.pathname}${window.location.search}`!==s&&(n?window.history.replaceState(null,"",s):window.history.pushState(null,"",s),window.dispatchEvent(new CustomEvent(vt)))}function ze(){let[t,n]=d($t);return z(()=>{let s=()=>n($t());return window.addEventListener("popstate",s),window.addEventListener(vt,s),()=>{window.removeEventListener("popstate",s),window.removeEventListener(vt,s)}},[]),t}var je=["vms","disks","media","networks","vnfs","lightvm","settings"],si={vms:{eyebrow:"Thin-LVM inventory",title:"Virtual machines",lede:"Create instances, attach disks and installation media, start them with QEMU/KVM, and open their consoles."},disks:{eyebrow:"Thin-LVM inventory",title:"Disks",lede:"Create and manage instance disks separately, then attach an unattached disk to a VM."},media:{eyebrow:"Installation media",title:"Installation media",lede:"Register read-only ISO files from the host, then choose one as a VM's boot media."},networks:{eyebrow:"VPP bridge domains",title:"Network",lede:"Create VPP bridge domains and vhost-user interfaces, then attach them to stopped VMs."},vnfs:{eyebrow:"Network functions",title:"VNFs",lede:"Attach DHCPv4 and DHCPv6 functions to Rookery Networks, enable them, and inspect their leases."},lightvm:{eyebrow:"LightVM inventory",title:"LightVM",lede:"Upload kernels and initramfs images, then define container-backed microVMs."},settings:{eyebrow:"Rookery server",title:"Settings",lede:"Configure cgroup resource limits, server-wide Rookery behavior, and lifecycle hooks."}};function ye(t){let n=document.getElementById(t);if(!n)throw new Error(`missing UI element #${t}`);return n}function Ns(){let t=ye("api-status"),n=ye("api-status-label"),s=ye("hero-eyebrow"),l=ye("hero-title"),a=ye("hero-lede"),o=ye("readiness-summary"),f=ye("thin-pool-card"),r=ye("thin-pool-state"),b=ye("thin-pool-target"),w=ye("thin-pool-detail"),u=ye("vpp-card"),k=ye("vpp-state"),h=ye("vpp-target"),A=ye("vpp-detail"),I={card:ye("rookery-startup-script-card"),state:ye("rookery-startup-script-state"),target:ye("rookery-startup-script-target"),detail:ye("rookery-startup-script-detail")},m={card:ye("vpp-startup-script-card"),state:ye("vpp-startup-script-state"),target:ye("vpp-startup-script-target"),detail:ye("vpp-startup-script-detail")},g={vms:ye("tab-vms"),disks:ye("tab-disks"),media:ye("tab-media"),networks:ye("tab-networks"),vnfs:ye("tab-vnfs"),lightvm:ye("tab-lightvm"),settings:ye("tab-settings")},P={vms:ye("vms-panel"),disks:ye("disks-panel"),media:ye("media-panel"),networks:ye("networks-panel"),vnfs:ye("vnfs-panel"),lightvm:ye("lightvm-panel"),settings:ye("settings-panel")},L={vms:ye("new-vm"),disks:ye("new-disk"),media:ye("new-media"),networks:ye("new-network")},S="vms",N={ready:"ready",checking:"checking",missing:"missing",not_configured:"not configured",unreachable:"unreachable",error:"error"},p=E=>{let M={online:"api online",offline:"api offline",pending:"connecting"};t.dataset.state=E,n.textContent=M[E]},T=(E,M,se,ce,v,$)=>{let C=v?.state||"checking";E.dataset.state=C,M.textContent=N[C]||C,se.textContent=$,se.title=$;let U=v?.detail||"";!U&&C==="checking"&&(U="Checking\u2026"),ce.textContent=U},H=E=>{if(!E)return"";let M=new Date(E);return Number.isNaN(M.getTime())?"":M.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})},G=(E,M,se)=>{let ce="unreachable",v="unavailable",$="status unavailable",C="Could not read the startup script status.";if(M){let U=M.status||(M.enabled?"pending":"disabled"),de=H(M.ran_at);switch(U){case"success":ce="ready",v="success",$=de?`last run ${de}`:"last run succeeded",C=`exit code ${M.exit_code??0}`;break;case"failed":ce="error",v="failed",$=de?`last run ${de}`:"last run failed",C=`exit code ${M.exit_code??"\u2014"} \xB7 open Settings for output`;break;case"running":ce="checking",v="running",$="running now",C=de?`started ${de}`:"Waiting for completion\u2026";break;case"pending":ce="missing",v="pending",$="not run yet",C=se==="vpp"?"Runs on the next VPP connection":"Runs on the next API start";break;case"disabled":ce="not_configured",v="disabled",$="disabled",C="Enable it in Settings";break;case"unavailable":ce="unreachable",v="unavailable",$="status unavailable",C="Could not read the startup script status.";break}}E.card.dataset.state=ce,E.state.textContent=v,E.target.textContent=$,E.target.title=$,E.detail.textContent=C},J=E=>{let M=!!E.ready,se=E.thin_pool,ce=E.vpp,v="not configured";if(se?.vg&&se.name?v=`${se.vg}/${se.name}`:se?.vg&&(v=se.vg),T(f,r,b,w,se,v),T(u,k,h,A,ce,ce?.socket||"not configured"),G(I,E.rookery_startup_script,"rookery"),G(m,E.vpp_startup_script,"vpp"),M)o.dataset.state="ready",o.textContent="ready for VMs";else{let U=[se?.state,ce?.state].filter(Boolean);U.includes("unreachable")||U.includes("error")?o.dataset.state="unreachable":U.includes("missing")||U.includes("not_configured")?o.dataset.state="missing":o.dataset.state="checking",o.textContent=o.dataset.state==="checking"?"checking":"VM creation blocked"}let $=!!se?.ready,C=!!ce?.ready;window.dispatchEvent(new CustomEvent("rookery:readiness-changed",{detail:{ready:M,thinPool:$}})),window.dispatchEvent(new CustomEvent("rookery:vpp-ready",{detail:C}))},ne=()=>{T(f,r,b,w,{state:"unreachable",detail:"Cannot reach the API."},"status unavailable"),T(u,k,h,A,{state:"unreachable",detail:"Cannot reach the API."},"status unavailable"),G(I,void 0,"rookery"),G(m,void 0,"vpp"),o.dataset.state="unreachable",o.textContent="status unavailable",window.dispatchEvent(new CustomEvent("rookery:readiness-changed",{detail:{ready:!1,thinPool:!1}})),window.dispatchEvent(new CustomEvent("rookery:vpp-ready",{detail:!1}))},x=async()=>{try{let E=await V("/api/status");J(E),p("online")}catch{p("offline"),ne()}},R=E=>je.includes(E),F=E=>{let M=E.split("/").filter(Boolean)[0]||"vms";return M==="storage-backends"?"disks":M==="interfaces"||M==="acls"||M==="route-tables"?"networks":R(M)?M:"vms"},W=(E,M=!1,se=!0)=>{let ce=R(E)?E:"vms",v=ce!==S;S=ce;for(let C of je){let U=C===ce;g[C].setAttribute("aria-selected",String(U)),g[C].tabIndex=U?0:-1,P[C].hidden=!U;let de=L[C];de&&(de.hidden=!U)}se&&Q(`/${ce}`);let $=si[ce];s.textContent=$.eyebrow,l.textContent=$.title,a.textContent=$.lede,document.title=`${$.title} \xB7 rookery`,v&&window.dispatchEvent(new CustomEvent("rookery:tab-changed",{detail:ce})),M&&g[ce].focus()},Y=E=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(E.key))return;E.preventDefault();let se=je.indexOf(S);E.key==="Home"?W(je[0],!0):E.key==="End"?W(je[je.length-1],!0):E.key==="ArrowLeft"?W(je[(se-1+je.length)%je.length],!0):W(je[(se+1)%je.length],!0)},y=()=>{let E=$t();E.path==="/"&&window.history.replaceState(null,"","/vms");let M=E.path.startsWith("/route-tables")||E.path==="/vnfs/new"||/^\/vnfs\/[^/]+\/edit$/.test(E.path);document.body.classList.toggle("route-page-mode",M),W(F(E.path),!1,!1)};for(let E of je)g[E].addEventListener("click",()=>W(E)),g[E].addEventListener("keydown",Y);L.vms?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-vm"))}),L.disks?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-disk"))}),L.media?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-media"))}),L.networks?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-network"))}),window.addEventListener("popstate",y),window.addEventListener(vt,y),window.addEventListener("rookery:open-tab",E=>{let M=String(E.detail||"");R(M)&&W(M)}),window.setInterval(()=>{document.hidden||x()},1e4);let ae=window.location.hash.replace(/^#/,"");ae&&R(ae)&&window.history.replaceState(null,"",`/${ae}`),y(),x()}var Rt=64;function Ne(t){let[n,s]=d({}),l=async()=>{try{let o=await V(`/api/labels/${encodeURIComponent(t)}`),f={};for(let r of Array.isArray(o)?o:[])f[r.entity_id]=Array.isArray(r.labels)?r.labels:[];s(f)}catch{s({})}};return z(()=>{l()},[t]),z(()=>{let o=f=>{let r=f.detail;!r||r.entityType!==t||!r.entityID||s(b=>({...b,[r.entityID]:Array.isArray(r.labels)?r.labels:[]}))};return window.addEventListener("rookery:labels-changed",o),()=>window.removeEventListener("rookery:labels-changed",o)},[t]),{labels:n,reload:l,setEntityLabels:(o,f)=>{s(r=>({...r,[o]:f}))}}}function ke(t,n){let[s,l]=d([]);return z(()=>{if(n==null||String(n)===""){l([]);return}let a=!0;return V(`/api/labels/${encodeURIComponent(t)}/${encodeURIComponent(String(n))}`).then(o=>{a&&l(Array.isArray(o.labels)?o.labels:[])}).catch(()=>{a&&l([])}),()=>{a=!1}},[t,n]),[s,l]}async function Ds(t,n,s){let l=await V(`/api/labels/${encodeURIComponent(t)}/${encodeURIComponent(String(n))}`,{method:"PUT",body:JSON.stringify({labels:s})}),a=Array.isArray(l.labels)?l.labels:[];return window.dispatchEvent(new CustomEvent("rookery:labels-changed",{detail:{entityType:t,entityID:String(n),labels:a}})),a}async function Ce(t,n,s,l){try{await Ds(t,n,l)}catch(a){oe(`Saved ${s}, but labels could not be updated: ${a instanceof Error?a.message:String(a)}`,!0)}}function Se({labels:t,onChange:n,disabled:s=!1}){let[l,a]=d(""),o=Array.isArray(t)?t:[],f=()=>{let r=l.split(/[\n,]+/).map(w=>w.trim()).filter(Boolean);if(r.length===0)return;let b=[...o];for(let w of r)b.includes(w)||b.push(w);n(b),a("")};return e("div",{class:"labels-field",children:[e("span",{class:"field-label",children:"Labels"}),e("div",{class:"labels-editor-list",children:o.length?o.map(r=>e("span",{class:"label-chip editable",children:[r,e("button",{type:"button","aria-label":`Remove ${r}`,disabled:s,onClick:()=>n(o.filter(b=>b!==r)),children:"\xD7"})]},r)):e("span",{class:"field-hint",children:"No labels yet."})}),e("div",{class:"labels-editor-input",children:[e("input",{type:"text",value:l,maxLength:256,disabled:s||o.length>=Rt,placeholder:"production, web, critical",onInput:r=>a(r.currentTarget.value),onKeyDown:r=>{r.key==="Enter"&&(r.preventDefault(),f())}}),e("button",{class:"button ghost",type:"button",disabled:s||!l.trim()||o.length>=Rt,onClick:f,children:"Add"})]}),e("span",{class:"field-hint",children:["Add as many labels as you need. Commas separate multiple labels; up to ",Rt,"."]})]})}function De({entityType:t,entityID:n,name:s,labels:l,onSaved:a}){let[o,f]=d(!1),r=Array.isArray(l)?l:[];return e("div",{class:"entity-labels",children:[r.map(b=>e("span",{class:"label-chip",title:b,children:b},b)),e("button",{class:"label-add",type:"button","aria-label":`Edit labels for ${s}`,onClick:()=>f(!0),children:r.length?"edit labels":"+ label"}),o?e(ai,{entityType:t,entityID:String(n),name:s,labels:r,onClose:()=>f(!1),onSaved:b=>{a(b),f(!1)}}):null]})}function ai({entityType:t,entityID:n,name:s,labels:l,onClose:a,onSaved:o}){let f=te(null),r=te(null),[b,w]=d(l),[u,k]=d(""),[h,A]=d(""),[I,m]=d(!1);z(()=>{f.current?.showModal(),r.current?.focus()},[]);let g=()=>{I||f.current?.close()},P=()=>{let S=u.split(/[\n,]+/).map(N=>N.trim()).filter(Boolean);S.length!==0&&(w(N=>{let p=[...N];for(let T of S)p.includes(T)||p.push(T);return p}),k(""),A(""))},L=async S=>{if(S.preventDefault(),!I){m(!0),A("");try{let N=await Ds(t,n,b);o(N),oe(`Saved labels for ${s}`)}catch(N){A(N instanceof Error?N.message:String(N))}finally{m(!1)}}};return e("dialog",{ref:f,class:"dialog labels-dialog","aria-labelledby":"labels-dialog-title",onClose:a,onClick:S=>{S.target===S.currentTarget&&g()},onCancel:S=>{I&&S.preventDefault()},children:e("form",{onSubmit:S=>{L(S)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Labels"}),e("h2",{id:"labels-dialog-title",children:s})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:g,children:"\xD7"})]}),e("div",{class:"labels-editor",children:[e("span",{class:"field-hint",children:["Add as many labels as you need. Labels are free-form text; commas separate multiple labels. Up to ",Rt," per ",t,"."]}),e("div",{class:"labels-editor-list",children:b.length?b.map(S=>e("span",{class:"label-chip editable",children:[S,e("button",{type:"button","aria-label":`Remove ${S}`,disabled:I,onClick:()=>w(N=>N.filter(p=>p!==S)),children:"\xD7"})]},S)):e("span",{class:"field-hint",children:"No labels yet."})}),e("div",{class:"labels-editor-input",children:[e("input",{ref:r,type:"text",value:u,maxLength:256,placeholder:"production, web, critical",onInput:S=>k(S.currentTarget.value),onKeyDown:S=>{S.key==="Enter"&&(S.preventDefault(),P())}}),e("button",{class:"button ghost",type:"button",disabled:!u.trim()||I,onClick:P,children:"Add"})]}),h?e("p",{class:"form-error",role:"alert",children:h}):null]}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:I,onClick:g,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:I,children:I?"Saving\u2026":"Save labels"})]})]})})}var oi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function Ms({backends:t,disks:n,onClose:s,onSaved:l}){let a=t.find(_=>_.name==="default")||t[0],[o,f]=d(t.length===0?"host":"lvm"),[r,b]=d(""),[w,u]=d("10"),[k,h]=d(a?String(a.id):""),[A,I]=d([]),[m,g]=d(""),[P,L]=d(!1),[S,N]=d(""),[p,T]=d("empty"),[H,G]=d(""),[J,ne]=d(!1),[x,R]=d(""),[F,W]=d("5m"),[Y,y]=d("{}"),[ae,E]=ke("disk"),[M,se]=d(!1),[ce,v]=d(""),$=te(null),C=te(null),U=te(null);z(()=>{$.current?.showModal(),C.current?.focus()},[]);let ie=!!(t.find(_=>String(_.id)===k)||null)?.config?.supports_templates,pe=n.filter(_=>_.is_template&&String(_.storage_backend_id??"")===k),ee=pe.find(_=>String(_.id)===H)||null,ue=A.find(_=>_.path===m)||null;z(()=>{ie||(T("empty"),G(""),ne(!1))},[ie]),z(()=>{if(o!=="host")return;let _=!1;return L(!0),N(""),V("/api/host/block-devices/attachable").then(B=>{_||I(Array.isArray(B)?B:[])}).catch(B=>{_||(I([]),N(B instanceof Error?B.message:String(B)))}).finally(()=>{_||L(!1)}),()=>{_=!0}},[o]);let me=()=>{M||$.current?.close()},c=async _=>{if(_.preventDefault(),M||!U.current?.reportValidity())return;if(o==="host"&&(!ue||!ue.available)){v("Select an available host block device.");return}let B;if(o==="lvm"&&p==="template"&&ee?.provisioning_script){try{B=JSON.parse(Y||"{}")}catch{v("Provisioning properties must be valid JSON.");return}if(B===null||Array.isArray(B)||typeof B!="object"){v("Provisioning properties must be a JSON object.");return}}v(""),se(!0);try{let X=o==="host"?{name:r.trim(),host_device:ue?.path}:{name:r.trim(),size_gb:Number(w),storage_backend_id:Number(k),...p==="template"&&ee?{template_disk_id:ee.id}:{},...p==="template"&&ee?.provisioning_script&&B!==void 0?{provisioning:B}:{},...p==="empty"&&ie?{is_template:J,...J?{provisioning_script:x.trim(),provisioning_timeout:F.trim()}:{}}:{}},Z=await V("/api/disks",{method:"POST",body:JSON.stringify(X)});await Ce("disk",Z.id,Z.name,ae),l(Z)}catch(X){v(X instanceof Error?X.message:String(X))}finally{se(!1)}};return e("dialog",{ref:$,id:"disk-dialog","aria-labelledby":"disk-dialog-title",onClose:s,onClick:_=>{_.target===_.currentTarget&&me()},onCancel:_=>{M&&_.preventDefault()},children:e("form",{ref:U,novalidate:!0,onSubmit:_=>{c(_)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"New disk"}),e("h2",{id:"disk-dialog-title",children:"Create disk"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:me,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:C,type:"text",required:!0,maxlength:127,pattern:oi,autocomplete:"off",spellcheck:!1,placeholder:"vm01-root",value:r,onInput:_=>b(_.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Storage source"}),e("select",{value:o,onChange:_=>f(_.currentTarget.value),children:[e("option",{value:"lvm",disabled:t.length===0,children:"LVM thin volume"}),e("option",{value:"host",children:"Host block device (passthrough)"})]}),e("span",{class:"field-hint",children:"Passthrough disks hand an existing host disk, partition, or logical volume to one VM. Rookery never deletes or resizes the underlying device."})]}),o==="lvm"?e(ge,{children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Size (GB)"}),e("input",{type:"number",required:!0,min:ee?ee.size_gb:1,max:65536,step:1,value:w,onInput:_=>u(_.currentTarget.value)}),e("span",{class:"field-hint",children:"Creates an LVM thin volume of this size in the configured pool."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Storage backend"}),e("select",{required:!0,value:k,onChange:_=>{h(_.currentTarget.value),G(""),T("empty")},children:t.map(_=>e("option",{value:String(_.id),children:[_.name," \xB7 ",_.driver==="lvm-thin"?"LVM thin":_.driver]},_.id))}),e("span",{class:"field-hint",children:ie?"Template cloning is available on this backend.":"Template cloning is disabled for this backend; enable it in Storage backends."})]}),ie?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Creation source"}),e("select",{value:p,onChange:_=>T(_.currentTarget.value),children:[e("option",{value:"empty",children:"Empty disk"}),e("option",{value:"template",disabled:pe.length===0,children:["From template",pe.length===0?" (none available)":""]})]}),e("span",{class:"field-hint",children:"Template clones run the backend preparation script and the template's provisioning script when one is configured; empty disks use the thin pool directly."})]}):null,ie&&p==="template"?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Template"}),e("select",{required:!0,value:H,onChange:_=>{let B=_.currentTarget.value;G(B);let X=pe.find(Z=>String(Z.id)===B);X&&u(String(X.size_gb))},children:[e("option",{value:"",children:"Select a template\u2026"}),pe.map(_=>e("option",{value:String(_.id),children:[_.name," \xB7 ",_.size_gb," GB",_.provisioning_script?" \xB7 provisioning":""]},_.id))]})]}):null,ie&&p==="empty"?e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:J,onChange:_=>ne(_.currentTarget.checked)}),e("span",{children:"Mark this new disk as a template"})]})}):null,ie&&p==="empty"&&J?e(ge,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"VM provisioning script"}),e("input",{type:"text",placeholder:"/usr/local/sbin/rookery-provision-template",value:x,onInput:_=>R(_.currentTarget.value)}),e("span",{class:"field-hint",children:["Optional absolute-path script run after a clone is created. It receives",e("code",{children:"ROOKERY_PROVISION_JSON"}),"."]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Provisioning timeout"}),e("input",{type:"text",placeholder:"5m",value:F,onInput:_=>W(_.currentTarget.value)})]})]}):null,ie&&p==="template"&&ee&&!ee.provisioning_script?e("p",{class:"field-hint field-wide",children:["This template has no provisioning script, so there are no key-values to pass. Close this dialog and use ",e("strong",{children:"Provisioning"})," on the template row to configure one first."]}):null,ie&&p==="template"&&ee?.provisioning_script?e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Provisioning properties (JSON object)"}),e("textarea",{rows:6,spellcheck:!1,value:Y,onInput:_=>y(_.currentTarget.value)}),e("span",{class:"field-hint",children:["Passed to the template provisioning script through",e("code",{children:"ROOKERY_PROVISION_JSON"})," under ",e("code",{children:"properties"}),". Set ",e("code",{children:'"resize_disk": true'})," to extend the root partition and filesystem with the reference script. Leave",e("code",{})," empty to skip provisioning."]})]}):null]}):e(ge,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Host block device"}),e("select",{required:!0,value:m,disabled:P,onChange:_=>{let B=_.currentTarget.value;g(B);let X=A.find(Z=>Z.path===B);X&&!r.trim()&&b(ii(X))},children:[e("option",{value:"",children:P?"Loading devices\u2026":"Select a host block device\u2026"}),A.map(_=>e("option",{value:_.path,disabled:!_.available,children:[xs(_.kind)," \xB7 ",_.path," \xB7 ",Vs(_.size_bytes),_.available?"":` \xB7 ${_.unavailable_reason||"unavailable"}`]},`${_.kind}:${_.path}`))]}),e("span",{class:"field-hint",children:"Whole disks, partitions, and logical volumes are listed. Mounted, swap, and otherwise busy devices are disabled."})]}),S?e("p",{class:"form-error field-wide",role:"alert",children:S}):null,ue?e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Selected device"}),e("p",{class:"field-hint",children:[xs(ue.kind)," \xB7 ",ue.path," \xB7"," ",Vs(ue.size_bytes),ue.model?` \xB7 ${ue.model}`:""]}),ue.available?null:e("p",{class:"form-error",role:"alert",children:ue.unavailable_reason||"This device is not available."})]}):null,A.length===0&&!P&&!S?e("p",{class:"field-hint field-wide",children:"No host block devices were found."}):null]}),e("div",{class:"field field-wide",children:e(Se,{labels:ae,onChange:E,disabled:M})})]}),ce?e("p",{class:"form-error",role:"alert",children:ce}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:M,onClick:me,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:M||(o==="lvm"?t.length===0||p==="template"&&!ee:!ue||!ue.available),children:M?"Creating\u2026":"Create disk"})]})]})})}function xs(t){switch(t){case"disk":return"Disk";case"partition":return"Partition";case"lv":return"Logical volume";case"raid":return"RAID array";default:return"Block device"}}function Vs(t){let n=Number(t)||0;return n>=1024**4?`${(n/1024**4).toFixed(n%1024**4===0?0:1)} TB`:n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function ii(t){return(t.path.split("/").filter(Boolean).pop()||t.name).replace(/[^A-Za-z0-9._-]+/g,"-").replace(/^[^A-Za-z0-9]+/,"").slice(0,127)||t.name}function $s({backend:t,onClose:n,onImported:s}){let[l,a]=d([]),[o,f]=d(()=>new Set),[r,b]=d(!0),[w,u]=d(!1),[k,h]=d(""),A=te(null),I=async()=>{b(!0),h("");try{let L=await V(`/api/storage-backends/${t.id}/importable-disks`);a(Array.isArray(L)?L:[]),f(new Set)}catch(L){a([]),h(L instanceof Error?L.message:String(L))}finally{b(!1)}};z(()=>{A.current?.showModal(),I()},[]);let m=()=>{w||A.current?.close()},g=(L,S)=>{f(N=>{let p=new Set(N);return S?p.add(L):p.delete(L),p})},P=async()=>{if(!(w||o.size===0)){u(!0),h("");try{let L=await V(`/api/storage-backends/${t.id}/import-disks`,{method:"POST",body:JSON.stringify({names:[...o]})});s(Array.isArray(L)?L:[])}catch(L){h(L instanceof Error?L.message:String(L))}finally{u(!1)}}};return e("dialog",{ref:A,id:"import-disk-dialog","aria-labelledby":"import-disk-dialog-title",onClose:n,onClick:L=>{L.target===L.currentTarget&&m()},onCancel:L=>{w&&L.preventDefault()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Storage backend"}),e("h2",{id:"import-disk-dialog-title",children:"Import existing disks"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("p",{class:"import-disk-lede",children:[t.name," \xB7 ",t.config.vg,"/",t.config.thin_pool," \u2014 select existing thin volumes to register as disks. Importing does not modify LVM, but deleting an imported disk later removes its volume."]}),k?e("div",{class:"banner",role:"alert",children:k}):null,e("div",{class:"import-disk-list",children:[r?e("p",{class:"import-disk-meta",children:"Loading existing thin volumes\u2026"}):null,!r&&l.length===0&&!k?e("div",{class:"empty import-disk-empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A3"}),e("h3",{children:"No thin volumes found"}),e("p",{children:"This storage backend has no existing thin volumes to import."})]}):null,r?null:l.map(L=>e("label",{class:L.registered?"import-disk-row imported":"import-disk-row",children:[e("input",{type:"checkbox",value:L.name,checked:o.has(L.name),disabled:L.registered||w,"aria-label":`Import ${L.name}`,onChange:S=>g(L.name,S.currentTarget.checked)}),e("span",{class:"import-disk-copy",children:[e("span",{class:"import-disk-name",children:L.name}),e("span",{class:"import-disk-meta",children:[L.size_gb," GB \xB7"," ",L.registered?L.attached_vm_name?`already imported \xB7 attached to ${L.attached_vm_name}`:L.storage_backend_name?`already imported \xB7 ${L.storage_backend_name}`:"already imported":"available to import"]})]}),e("span",{class:"import-disk-badge",children:L.registered?"imported":"new"})]},L.name))]}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:r||w,onClick:()=>{I()},children:r?"Refreshing\u2026":"Refresh"}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"button",disabled:w||o.size===0,onClick:()=>{P()},children:w?"Importing\u2026":o.size>0?`Import selected (${o.size})`:"Import selected"})]})]})}var ri="[A-Za-z0-9][A-Za-z0-9._\\-]*";function Rs({backend:t,onClose:n,onSaved:s}){let l=t!==null,[a,o]=d(t?.name??""),[f,r]=d(t?.title??""),[b,w]=d(t?.description??""),[u,k]=d(t?.driver??"lvm-thin"),[h,A]=d(t?.config?.vg??""),[I,m]=d(t?.config?.thin_pool??""),[g,P]=d(!!t?.config?.supports_templates),[L,S]=d(t?.config?.preparation_script??""),[N,p]=d(t?.config?.preparation_timeout||"5m"),[T,H]=d(!!t?.config?.allow_template_delete_with_clones),[G,J]=ke("storage_backend",t?.id),[ne,x]=d(!1),[R,F]=d(""),W=te(null),Y=te(null),y=te(null);z(()=>{W.current?.showModal(),Y.current?.focus()},[]);let ae=()=>{ne||W.current?.close()},E=async M=>{if(M.preventDefault(),!(ne||!y.current?.reportValidity())){F(""),x(!0);try{let se=await V(l?`/api/storage-backends/${t?.id}`:"/api/storage-backends",{method:l?"PUT":"POST",body:JSON.stringify({name:a.trim(),title:f.trim(),description:b.trim(),driver:u,config:{vg:h.trim(),thin_pool:I.trim(),supports_templates:g,preparation_script:g?L.trim():"",preparation_timeout:g?N.trim()||"5m":"",allow_template_delete_with_clones:g&&T}})});await Ce("storage_backend",se.id,se.name,G),s(se)}catch(se){F(se instanceof Error?se.message:String(se))}finally{x(!1)}}};return e("dialog",{ref:W,id:"storage-backend-dialog","aria-labelledby":"storage-backend-dialog-title",onClose:n,onClick:M=>{M.target===M.currentTarget&&ae()},onCancel:M=>{ne&&M.preventDefault()},children:e("form",{ref:y,novalidate:!0,onSubmit:M=>{E(M)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:l?"Edit backend":"New backend"}),e("h2",{id:"storage-backend-dialog-title",children:l?`Storage backend \xB7 ${t?.name}`:"Register storage backend"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:ae,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:Y,type:"text",required:!0,maxlength:64,pattern:ri,autocomplete:"off",spellcheck:!1,placeholder:"fast-pool",value:a,onInput:M=>o(M.currentTarget.value)}),e("span",{class:"field-hint",children:"Used when creating disks."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Title"}),e("input",{type:"text",maxlength:128,autocomplete:"off",placeholder:"Fast NVMe thin pool",value:f,onInput:M=>r(M.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:2,maxlength:2e3,placeholder:"What this backend is for",value:b,onInput:M=>w(M.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Driver"}),e("select",{required:!0,value:u,onChange:M=>k(M.currentTarget.value),children:e("option",{value:"lvm-thin",children:"LVM thin pool"})}),e("span",{class:"field-hint",children:"Additional drivers can be added later."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Volume group"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"vg0",value:h,onInput:M=>A(M.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Thin pool"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"rookery",value:I,onInput:M=>m(M.currentTarget.value)})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"field-label",children:"Templates"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:g,onChange:M=>P(M.currentTarget.checked)}),e("span",{children:"Support template disks and template-based disk creation"})]}),e("span",{class:"field-hint",children:"Clone existing template disks with the backend preparation script."})]}),g?e(ge,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Preparation script"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"/usr/local/sbin/rookery-disk-prepare",value:L,onInput:M=>S(M.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path. Runs as the API user with disk details in ROOKERY_DISK_JSON."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Script timeout"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"5m",value:N,onInput:M=>p(M.currentTarget.value)})]}),e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:T,onChange:M=>H(M.currentTarget.checked)}),e("span",{children:"Allow deleting a template while clones still exist"})]})})]}):null,e("div",{class:"field field-wide",children:e(Se,{labels:G,onChange:J,disabled:ne})})]}),R?e("p",{class:"form-error",role:"alert",children:R}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:ne,onClick:ae,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:ne,children:ne?"Saving\u2026":"Save backend"})]})]})})}function Fs({disk:t,onClose:n,onSaved:s}){let[l,a]=d(t.provisioning_script||""),[o,f]=d(t.provisioning_timeout||""),[r,b]=d(!1),[w,u]=d(""),k=te(null);z(()=>{k.current?.showModal()},[]);let h=()=>{r||k.current?.close()},A=async I=>{if(I.preventDefault(),!r){b(!0),u("");try{let m=await V(`/api/disks/${t.id}`,{method:"PUT",body:JSON.stringify({provisioning_script:l.trim(),provisioning_timeout:o.trim()})});s(m)}catch(m){u(m instanceof Error?m.message:String(m))}finally{b(!1)}}};return e("dialog",{ref:k,id:"template-provisioning-dialog","aria-labelledby":"template-provisioning-title",onClose:n,onClick:I=>{I.target===I.currentTarget&&h()},onCancel:I=>{r&&I.preventDefault()},children:e("form",{onSubmit:I=>{A(I)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Template provisioning"}),e("h2",{id:"template-provisioning-title",children:["Provisioning \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:h,children:"\xD7"})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Provisioning script"}),e("input",{type:"text",placeholder:"/usr/local/sbin/rookery-provision-template",value:l,onInput:I=>a(I.currentTarget.value)}),e("span",{class:"field-hint",children:["Absolute path to a script run after the backend clones this template. It receives the provisioning context and custom properties in ",e("code",{children:"ROOKERY_PROVISION_JSON"}),". Leave empty to disable provisioning."]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Timeout"}),e("input",{type:"text",placeholder:"5m",value:o,onInput:I=>f(I.currentTarget.value)}),e("span",{class:"field-hint",children:"Examples: 30s, 5m, 1h."})]}),w?e("p",{class:"form-error",role:"alert",children:w}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:r,onClick:h,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:r,children:r?"Saving\u2026":"Save provisioning"})]})]})})}var Bs=new Intl.NumberFormat;function Hs(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function li(t){let n=Number(t.size_bytes)||0;return n<=0?`${Bs.format(t.size_gb)} GB`:n>=1024**4?`${(n/1024**4).toFixed(n%1024**4===0?0:1)} TB`:n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:`${Bs.format(n)} B`}function Os(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d(!0),[f,r]=d(""),[b,w]=d(""),[u,k]=d(!1),[h,A]=d(null),[I,m]=d(null),[g,P]=d(null),L=Ne("disk"),S=Ne("storage_backend"),N=ze();z(()=>{P(null)},[N.path,N.search]),z(()=>{let[y,ae,E]=N.segments;if(y==="disks"){k(ae==="new"),A(null),m(null);return}if(y==="storage-backends"){if(k(!1),ae==="new"){A({backend:null}),m(null);return}let M=s.find(se=>String(se.id)===ae);M?(A(E==="edit"?{backend:M}:null),m(E==="import"?M:null)):(A(null),m(null));return}k(!1),A(null),m(null)},[N.path,s]);let p=y=>{n(y),window.dispatchEvent(new CustomEvent("rookery:disks-changed",{detail:y}))},T=async(y=!0)=>{try{let ae=await V("/api/disks");p(Array.isArray(ae)?ae:[]),y&&r("")}catch(ae){y&&r(ae instanceof Error?ae.message:String(ae))}},H=async(y=!0)=>{try{let ae=await V("/api/storage-backends"),E=Array.isArray(ae)?ae:[];E.sort((M,se)=>M.name.localeCompare(se.name,void 0,{sensitivity:"base"})),l(E),y&&w("")}catch(ae){y&&w(ae instanceof Error?ae.message:String(ae))}},G=async()=>{o(!0),await Promise.allSettled([T(),H()]),o(!1)};z(()=>{G();let y=M=>{let se=M.detail;Array.isArray(se)&&n(se)},ae=()=>{Q("/disks/new")},E=M=>{M.detail==="disks"&&G()};return window.addEventListener("rookery:disks-data",y),window.addEventListener("rookery:add-disk",ae),window.addEventListener("rookery:tab-changed",E),()=>{window.removeEventListener("rookery:disks-data",y),window.removeEventListener("rookery:add-disk",ae),window.removeEventListener("rookery:tab-changed",E)}},[]),z(()=>{let y=document.getElementById("disk-tab-count");y&&(y.textContent=String(t.length))},[t.length]);let J=async y=>{if(y.attached_vm_id){oe(`Detach ${y.name} from ${y.attached_vm_name||"its VM"} before deleting it.`,!0);return}if(window.confirm(y.source==="host-device"?`Delete disk "${y.name}"? - -This removes only the inventory record. The host block device ${y.device} is not modified.`:`Delete disk "${y.name}"? - -This removes the LVM thin volume and its inventory record.`))try{await V(`/api/disks/${y.id}`,{method:"DELETE"}),p(t.filter(E=>E.id!==y.id)),r(""),oe(`Deleted disk ${y.name}`),H(!1)}catch(E){r(`Could not delete ${y.name}: ${E instanceof Error?E.message:String(E)}`)}},ne=async y=>{if(y.source==="host-device"){oe("Host block devices cannot be templates.",!0);return}if(y.attached_vm_id){oe("Detach the disk from its VM before changing template status.",!0);return}try{let ae=await V(`/api/disks/${y.id}`,{method:"PUT",body:JSON.stringify({is_template:!y.is_template})});p(t.map(E=>E.id===ae.id?ae:E)),oe(ae.is_template?`Marked ${ae.name} as a template`:`Unmarked ${ae.name} as a template`)}catch(ae){r(`Could not update ${y.name}: ${ae instanceof Error?ae.message:String(ae)}`)}},x=async y=>{if(y.disk_count>0){oe("Delete disks using this backend first.",!0);return}if(window.confirm(`Delete storage backend "${y.name}"?`))try{await V(`/api/storage-backends/${y.id}`,{method:"DELETE"}),l(s.filter(E=>E.id!==y.id)),w(""),oe(`Deleted storage backend ${y.name}`)}catch(E){w(`Could not delete ${y.name}: ${E instanceof Error?E.message:String(E)}`)}},R=t.filter(y=>y.attached_vm_id).length,F=t.length===0?a?"Loading disks\u2026":"No disks registered":`${t.length} disk${t.length===1?"":"s"} \xB7 ${R} attached`,W=s.reduce((y,ae)=>y+(ae.disk_count||0),0),Y=s.length===0?a?"Loading storage backends\u2026":"No storage backends registered":`${s.length} backend${s.length===1?"":"s"} \xB7 ${W} disk${W===1?"":"s"}`;return e(ge,{children:[e("section",{class:"panel","aria-labelledby":"storage-backends-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"storage-backends-heading",children:"Storage backends"}),e("p",{class:"panel-subtitle",children:Y})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{G()},children:a?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/storage-backends/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add backend"]})]})]}),b?e("div",{class:"banner",role:"alert",children:b}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Driver"}),e("th",{scope:"col",children:"Location"}),e("th",{scope:"col",children:"Disks"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:s.map(y=>{let ae=y.driver==="lvm-thin"&&y.config?.vg&&y.config?.thin_pool;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:y.name}),y.title||y.description?e("span",{class:"vm-description",title:y.description||y.title||"",children:[y.title,y.description].filter(Boolean).join(" \xB7 ")}):null,e(De,{entityType:"storage_backend",entityID:y.id,name:y.name,labels:S.labels[String(y.id)],onSaved:E=>S.setEntityLabels(String(y.id),E)})]}),e("td",{class:"mono",children:y.driver==="lvm-thin"?"LVM thin":y.driver}),e("td",{class:"mono",children:[y.config?.vg||"\u2014","/",y.config?.thin_pool||"\u2014",y.config?.supports_templates?e("span",{class:"import-disk-badge template-backend-badge",children:"templates"}):null]}),e("td",{class:y.disk_count?"mono":"mono muted",children:String(y.disk_count||0)}),e("td",{class:"updated hide-sm",children:Hs(y.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!ae,title:ae?"Import existing thin volumes from this backend.":"Import is available for configured LVM thin backends.",onClick:()=>Q(`/storage-backends/${y.id}/import`),children:"Import"}),e("button",{class:"row-action",type:"button",onClick:()=>Q(`/storage-backends/${y.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",disabled:y.disk_count>0,title:y.disk_count>0?"Delete disks using this backend first.":"",onClick:()=>{x(y)},children:"Delete"})]})})]},y.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A4"}),e("h3",{children:"No storage backends yet"}),e("p",{children:"Register an LVM thin pool before creating disks."}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/storage-backends/new"),children:"Add a storage backend"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"disks-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"disks-heading",children:"Disks"}),e("p",{class:"panel-subtitle",children:F})]}),e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{G()},children:a?"Refreshing\u2026":"Refresh"})]}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Backend"}),e("th",{scope:"col",children:"Attached to"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(y=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:y.name}),y.is_template?e("span",{class:"import-disk-badge",children:"template"}):null,y.source==="host-device"?e("span",{class:"import-disk-badge",children:"passthrough"}):null,y.template_disk_name?e("span",{class:"vm-description",children:["from ",y.template_disk_name]}):null,y.device?e("span",{class:"vm-description",title:y.device,children:y.device}):null,e(De,{entityType:"disk",entityID:y.id,name:y.name,labels:L.labels[String(y.id)],onSaved:ae=>L.setEntityLabels(String(y.id),ae)})]}),e("td",{class:"mono",children:li(y)}),e("td",{class:y.storage_backend_name||y.source==="host-device"?"mono":"mono muted",children:y.source==="host-device"?"host device":y.storage_backend_name||"\u2014"}),e("td",{class:y.is_template?"mono muted":y.attached_vm_id?"mono":"mono muted",children:y.is_template?"template":y.attached_vm_id?y.attached_vm_name||y.attached_vm_id:"unattached"}),e("td",{class:"updated hide-sm",children:Hs(y.updated_at)}),e("td",{children:e("div",{class:"actions",children:[y.is_template?e("button",{class:"row-action",type:"button",onClick:()=>P(y),children:"Provisioning"}):null,y.source==="host-device"?null:e("button",{class:"row-action",type:"button",disabled:!!y.attached_vm_id,title:y.is_template?"Allow this disk to be used as a normal disk again.":"Mark this unattached disk as a template.",onClick:()=>{ne(y)},children:y.is_template?"Unmark":"Template"}),e("button",{class:"row-action danger",type:"button",disabled:!!y.attached_vm_id,title:y.attached_vm_id?`Detach ${y.name} from ${y.attached_vm_name||"its VM"} before deleting it.`:"",onClick:()=>{J(y)},children:"Delete"})]})})]},y.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A3"}),e("h3",{children:"No disks yet"}),e("p",{children:["Create an LVM thin disk here, pass through a host block device, or use"," ",e("strong",{children:"Import"})," on a storage backend to register existing thin volumes."]}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/disks/new"),children:"Create a disk"})]})})]}),g?e(Fs,{disk:g,onClose:()=>P(null),onSaved:y=>{P(null),p(t.map(ae=>ae.id===y.id?y:ae)),oe(`Saved provisioning for ${y.name}`)}}):null,u?e(Ms,{backends:s,disks:t,onClose:()=>{k(!1),Q("/disks",!0)},onSaved:y=>{k(!1),Q("/disks",!0),r(""),oe(`Created disk ${y.name}`),T(),H(!1)}}):null,h?e(Rs,{backend:h.backend,onClose:()=>{A(null),Q("/disks",!0)},onSaved:y=>{A(null),Q("/disks",!0),w(""),oe(`${h.backend?"Saved":"Added"} storage backend ${y.name}`),H(),T(!1)}},h.backend?`edit-${h.backend.id}`:"new"):null,I?e($s,{backend:I,onClose:()=>{m(null),Q("/disks",!0)},onImported:y=>{m(null),Q("/disks",!0),r(""),oe(`Imported ${y.length} disk${y.length===1?"":"s"}`),T(),H(!1)}},`import-${I.id}`):null]})}function Us(t){t&&Fe(e(Os,{}),t)}function di(t){let n=t.response;return n&&typeof n=="object"&&"error"in n&&typeof n.error=="string"?n.error:t.statusText||`Upload failed (${t.status})`}function qs(t,n,s){return new Promise((l,a)=>{let o=new FormData;o.append("kind",t),o.append("file",n,n.name);let f=new XMLHttpRequest;f.open("POST","/api/lightvm/uploads"),f.responseType="json",f.upload.onprogress=r=>{r.lengthComputable&&r.total>0&&s?.(r.loaded/r.total)},f.onerror=()=>a(new Error("Upload failed because the API could not be reached")),f.onabort=()=>a(new Error("Upload was cancelled")),f.onload=()=>{if(f.status>=200&&f.status<300&&f.response){l(f.response);return}a(new Error(di(f)))},f.send(o)})}async function Ws(t){t&&await V(`/api/lightvm/uploads?path=${encodeURIComponent(t)}`,{method:"DELETE"})}var ci={kernel:{endpoint:"/api/lightvm/kernels",entityType:"lightvm_kernel",uploadKind:"kernel",noun:"kernel",namePlaceholder:"vmlinuz-6.12"},initramfs:{endpoint:"/api/lightvm/initramfs",entityType:"lightvm_initramfs",uploadKind:"initramfs",noun:"initramfs",namePlaceholder:"initramfs-6.12"}},ui="[A-Za-z0-9][A-Za-z0-9._\\-]*";function zs(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(1)} MB`:n>=1024?`${(n/1024).toFixed(1)} KB`:`${n} B`}function Gs({kind:t,artifact:n,onClose:s,onSaved:l}){let a=ci[t],o=n!==null,[f,r]=d(n?.name||""),[b,w]=d(n?.description||""),[u,k]=d(null),[h,A]=ke(a.entityType,n?.id),[I,m]=d(!1),[g,P]=d(null),[L,S]=d(""),N=te(null),p=te(null),T=te(null);z(()=>{N.current?.showModal(),p.current?.focus()},[]);let H=()=>{I||N.current?.close()},G=async J=>{if(J.preventDefault(),I||!T.current?.reportValidity())return;if(!o&&!u){S(`Choose an ${a.noun} file to upload.`);return}S(""),m(!0);let ne="";try{let x=n?.path||"",R=n?.file_name||"",F=n?.size_bytes||0;if(u){P(0);let y=await qs(a.uploadKind,u,P);ne=y.path,x=y.path,R=y.file_name,F=y.size_bytes}let W={name:f.trim(),description:b.trim(),path:x,file_name:R},Y=o?await V(`${a.endpoint}/${n.id}`,{method:"PUT",body:JSON.stringify(W)}):await V(a.endpoint,{method:"POST",body:JSON.stringify(W)});ne="",await Ce(a.entityType,Y.id,Y.name,h),l({...Y,size_bytes:Y.size_bytes||F},!o)}catch(x){ne&&await Ws(ne).catch(()=>{}),S(x instanceof Error?x.message:String(x))}finally{m(!1),P(null)}};return e("dialog",{ref:N,id:`${a.uploadKind}-dialog`,"aria-labelledby":`${a.uploadKind}-dialog-title`,onClose:s,onClick:J=>{J.target===J.currentTarget&&H()},onCancel:J=>{I&&J.preventDefault()},children:e("form",{ref:T,novalidate:!0,onSubmit:J=>{G(J)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:o?`Edit ${a.noun}`:`New ${a.noun}`}),e("h2",{id:`${a.uploadKind}-dialog-title`,children:o?`Edit LightVM ${a.noun}`:`Add LightVM ${a.noun}`})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:H,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:p,type:"text",required:!0,maxlength:127,pattern:ui,autocomplete:"off",spellcheck:!1,placeholder:a.namePlaceholder,value:f,onInput:J=>r(J.currentTarget.value)}),e("span",{class:"field-hint",children:["A short label for this ",a.noun," image."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Description"}),e("input",{type:"text",maxlength:2e3,autocomplete:"off",placeholder:"Optional notes, version, or source",value:b,onInput:J=>w(J.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:o?`Replace ${a.noun} file`:`${a.noun[0].toUpperCase()}${a.noun.slice(1)} file`}),e("input",{type:"file",required:!o,disabled:I,onChange:J=>k(J.currentTarget.files?.[0]||null)}),e("span",{class:"field-hint",children:o&&n?`Current file: ${n.file_name||n.path} (${zs(n.size_bytes)}). Choose a file only to replace it.`:"The file is uploaded to the Rookery host and stored under the configured LightVM directory."}),u?e("span",{class:"field-hint",children:["Selected: ",u.name," (",zs(u.size),")"]}):null]}),g!==null?e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Uploading"}),e("progress",{max:100,value:Math.round(g*100)}),e("span",{class:"field-hint",children:[Math.round(g*100),"%"]})]}):null,e("div",{class:"field field-wide",children:e(Se,{labels:h,onChange:A,disabled:I})})]}),L?e("p",{class:"form-error",role:"alert",children:L}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:I,onClick:H,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:I,children:I?g!==null?"Uploading\u2026":"Saving\u2026":o?`Save ${a.noun}`:`Add ${a.noun}`})]})]})})}var pi="[A-Za-z0-9][A-Za-z0-9._\\-]*",fi=/^[A-Za-z_][A-Za-z0-9_]*$/,mn="console=hvc0 rdinit=/init";function mi(t){return Array.isArray(t?.consoles)&&t.consoles.length>0?t.consoles.map(n=>({id:n.id,name:n.name})):[{id:0,name:"console"}]}function hi(t){if(t.length===0)return"console";let n=new Set(t.map(l=>l.name.trim().toLowerCase())),s=t.length+1;for(;n.has(`console-${s}`);)s+=1;return`console-${s}`}function bi(t){return Array.isArray(t?.env)?t.env.map(n=>`${n.name}=${n.value}`).join(` -`):""}function gi(t){let n=[],s=new Set,l=t.split(` -`);for(let a=0;a128)return{env:[],error:`Environment variable ${w} on line ${a+1} is too long.`};if(u.length>4096)return{env:[],error:`Environment value for ${w} on line ${a+1} is too long.`};if(u.includes("\0"))return{env:[],error:`Environment value for ${w} on line ${a+1} contains a NUL character.`};if(s.has(w))return{env:[],error:`Environment variable ${w} is duplicated (line ${a+1}).`};s.add(w),n.push({name:w,value:u})}return{env:n,error:""}}function vi(t){return Array.isArray(t?.interfaces)?t.interfaces:[]}function Ks({vm:t,kernels:n,initramfs:s,interfaces:l,onClose:a,onSaved:o}){let f=t!==null,[r,b]=d(t?.name||""),[w,u]=d(t?.description||""),[k,h]=d(String(t?.vcpus??1)),[A,I]=d(String(t?.memory_mb??512)),[m,g]=d(!!t?.console_enabled),[P,L]=d(null),[S,N]=d(t?.cgroup_class||""),[p,T]=d(t?.kernel_id?String(t.kernel_id):""),[H,G]=d(t?.initramfs_id?String(t.initramfs_id):""),[J,ne]=d(t?.image||""),[x,R]=d(()=>bi(t)),[F,W]=d(()=>mi(t)),[Y,y]=d(t?.executable_path||""),[ae,E]=d(Array.isArray(t?.executable_args)?t.executable_args.join(` -`):""),[M,se]=d(t?t.append||"":mn),[ce,v]=d(()=>vi(t)),[$,C]=d(""),[U,de]=d(""),[ie,pe]=ke("lightvm",t?.id),[ee,ue]=d(!1),[me,c]=d(""),_=te(null),B=te(null),X=te(null),Z=new Set(ce.map(q=>q.id)),fe=Array.isArray(P?.classes)?P.classes:[],Ie=fe.filter(q=>!fe.some(we=>we.name.startsWith(q.name+"/"))),le=l.filter(q=>Z.has(q.id)||q.attached_vm_id?!1:!q.attached_lightvm_id||t!==null&&q.attached_lightvm_id===t.id);z(()=>{_.current?.showModal(),B.current?.focus()},[]),z(()=>{V("/api/cgroup-settings").then(L).catch(()=>L(null))},[]),z(()=>{le.length===0&&ce.length===0?de("Create a Rookery Virtual Interface on the Network tab first."):de("Choose an interface and click Add. Interfaces attached to VMs or other LightVMs are hidden.")},[le.length,ce.length]);let be=()=>{ee||_.current?.close()},ve=()=>{let q=Number($);if(!q)return;let we=l.find(Te=>Te.id===q)||ce.find(Te=>Te.id===q);we&&(v(Te=>Te.some(xe=>xe.id===we.id)?Te:[...Te,we]),C(""))},K=q=>{v(we=>we.filter(Te=>Te.id!==q))},$e=(q,we)=>{W(Te=>Te.map((xe,Oe)=>Oe===q?{...xe,name:we}:xe))},O=q=>{W(we=>we.filter((Te,xe)=>xe!==q))},_e=()=>{W(q=>[...q,{id:0,name:hi(q)}])},Ee=async q=>{if(q.preventDefault(),ee||!X.current?.reportValidity())return;let we=Number(p),Te=Number(H);if(!we||!Te){c("Select a kernel and an initramfs image.");return}if(F.length===0){c("Add at least one console.");return}let xe=[],Oe=new Set;for(let Ve of F){let Le=Ve.name.trim();if(!Le){c("Every console needs a name.");return}let Je=Le.toLowerCase();if(Oe.has(Je)){c(`Console ${Le} is duplicated.`);return}Oe.add(Je),xe.push({id:Ve.id,name:Le})}let et=gi(x);if(et.error){c(et.error);return}let Ke=ae.split(` -`).map(Ve=>Ve.replace(/\r$/,"")).filter(Ve=>Ve.length>0),dt={name:r.trim(),description:w.trim(),vcpus:Number(k),memory_mb:Number(A),console_enabled:m,cgroup_class:S,kernel_id:we,initramfs_id:Te,image:J.trim(),env:et.env,executable_path:Y.trim(),executable_args:Ke,append:M.trim(),consoles:xe,interface_ids:ce.map(Ve=>Ve.id)};c(""),ue(!0);try{let Ve=await V(f?`/api/lightvm/instances/${t.id}`:"/api/lightvm/instances",{method:f?"PUT":"POST",body:JSON.stringify(dt)});await Ce("lightvm",Ve.id,Ve.name||r.trim(),ie),o(Ve)}catch(Ve){c(Ve instanceof Error?Ve.message:String(Ve))}finally{ue(!1)}};return e("dialog",{ref:_,id:"lightvm-dialog","aria-labelledby":"lightvm-dialog-title",onClose:a,onClick:q=>{q.target===q.currentTarget&&be()},onCancel:q=>{ee&&q.preventDefault()},children:e("form",{ref:X,novalidate:!0,onSubmit:q=>{Ee(q)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:f?"Edit LightVM":"New LightVM"}),e("h2",{id:"lightvm-dialog-title",children:f?`Edit ${t?.name}`:"Create LightVM"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:be,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:B,type:"text",required:!0,maxlength:64,pattern:pi,autocomplete:"off",spellcheck:!1,placeholder:"app-01",value:r,onInput:q=>b(q.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"vCPUs"}),e("input",{type:"number",required:!0,min:1,max:128,step:1,value:k,onInput:q=>h(q.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory (MB)"}),e("input",{type:"number",required:!0,min:128,max:1048576,step:128,value:A,onInput:q=>I(q.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Kernel"}),e("select",{required:!0,value:p,onChange:q=>T(q.currentTarget.value),children:[e("option",{value:"",children:"Select a kernel\u2026"}),n.map(q=>e("option",{value:String(q.id),children:q.name},q.id))]}),e("span",{class:"field-hint",children:n.length===0?"Upload a kernel on the Kernels subtab first.":""})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Initramfs"}),e("select",{required:!0,value:H,onChange:q=>G(q.currentTarget.value),children:[e("option",{value:"",children:"Select an initramfs\u2026"}),s.map(q=>e("option",{value:String(q.id),children:q.name},q.id))]}),e("span",{class:"field-hint",children:s.length===0?"Upload an initramfs on the Initramfs subtab first.":""})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Kernel append"}),e("input",{type:"text",maxlength:4096,autocomplete:"off",spellcheck:!1,placeholder:mn,value:M,onInput:q=>se(q.currentTarget.value)}),e("span",{class:"field-hint",children:["Kernel command line appended at boot. Defaults to ",mn,"."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Container image"}),e("input",{type:"text",required:!0,maxlength:512,autocomplete:"off",spellcheck:!1,placeholder:"ghcr.io/example/app:latest",value:J,onInput:q=>ne(q.currentTarget.value)}),e("span",{class:"field-hint",children:"Docker/OCI image reference used by the LightVM runtime."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Executable path"}),e("input",{type:"text",required:!0,maxlength:4096,autocomplete:"off",spellcheck:!1,placeholder:"/usr/local/bin/app",value:Y,onInput:q=>y(q.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path to the executable inside the container image."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Arguments"}),e("textarea",{rows:4,spellcheck:!1,placeholder:`--listen -:8080`,value:ae,onInput:q=>E(q.currentTarget.value)}),e("span",{class:"field-hint",children:["One argument per line. Rookery writes the executable path and these arguments to an executable ",e("code",{children:".entrypoint"})," script at the image root."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Environment variables"}),e("textarea",{rows:6,spellcheck:!1,autocomplete:"off",placeholder:`APP_ENV=production +var Ne,ss,Bo,ot,es,Et,as,Tt,os,nn,tn,Ho,Lt={},It=[],Oo=/^m(i|n|o|s|text|space)$/,sn=Array.isArray,an=It.slice,ct=Object.assign;function Nt(t){t&&t.parentNode&&t.remove()}function is(t,n,s){var c,a,o,f={},r=arguments.length;for(o in n)o=="key"?c=n[o]:o=="ref"&&typeof t!="function"?a=n[o]:f[o]=n[o];return r>2&&(f.children=r>3?an.call(arguments,2):s),ht(t,f,c,a,null)}function ht(t,n,s,c,a){var o={type:t,props:n,key:s,ref:c,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:a||++ss,__i:-1,__u:0};return!a&&Ne.vnode&&Ne.vnode(o),o}function ye(t){return t.children}function Pt(t,n){this.props=t,this.context=n,this.__g=0}function it(t,n){if(n==null)return t.__?it(t.__,t.__i+1):null;for(var s;nn&&ot.sort(as),t=ot.shift(),n=ot.length,8&t.__g&&(c=void 0,a=void 0,o=(a=(s=t).__v).__e,f=[],r=[],(g=s.__P)&&((c=ct({constructor:void 0},a)).__v=a.__v+1,Ne.vnode&&Ne.vnode(c),on(g,c,a,s.__n,g.namespaceURI,32&a.__u?[o]:null,f,o||it(a),32&a.__u,r),c.__v=a.__v,c.__.__k[c.__i]=c,us(f,c,r),a.__=a.__e=null,c.__e!=o&&rs(c)))}finally{ot.length=Et=0}}function ls(t,n,s,c,a,o,f,r,g,w,d){var k,h,L,T,m,y,C=c.__k||It,E=n.length;for(g=qo(s,n,C,g,E),k=0;kg?C--:C++,E=!0)):(~w||(a>m?C--:a>1](d?1:0)){for(a=s-1,o=s+1;a>=0||o=0?a--:o++])&&!(2&w.__u)&&r==w.key&&g==w.type)return f}return-1}function ns(t,n,s){s==null&&(s=""),n[0]=="-"?t.setProperty(n,s):t[n]=s}function St(t,n,s,c,a){var o,f;e:if(n=="style")if(typeof s=="string")t.style.cssText=s;else{if(typeof c=="string"&&(t.style.cssText=c=""),c)for(n in c)s&&n in s||ns(t.style,n,"");if(s)for(n in s)c&&s[n]==c[n]||ns(t.style,n,s[n])}else if(n[0]=="o"&&n[1]=="n")(t.__e||(t.__e={}))[n]=s,s&&c||(f=tn[n]||(tn[n]=Go(n)),(t.__a||(t.__a={}))[n]=nn,o=n!=(n=n.replace(os,"$1")),(n=n.slice(2))[0]<"a"&&(n=n.toLowerCase()),s?t.addEventListener(n,f,o):t.removeEventListener(n,f,o));else{if(a=="http://www.w3.org/2000/svg")n=n.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(n!="width"&&n!="height"&&n!="href"&&n!="list"&&n!="form"&&n!="tabIndex"&&n!="download"&&n!="rowSpan"&&n!="colSpan"&&n!="role"&&n!="popover"&&n in t)try{t[n]=s??"";break e}catch{}typeof s=="function"||(s==null||s===!1&&n[4]!="-"?t.removeAttribute(n):t.setAttribute(n,n=="popover"&&s==1?"":s))}}function Go(t){return function(n){if(this.__e){var s=this.__e[t];if(n[Tt]==null)n[Tt]=nn++;else if(n[Tt]=s.__.length&&s.__.push({}),s.__[t]}function l(t){return Dt=1,jo(Cs,t)}function jo(t,n,s){var c=fn(gt++,2);if(c.t=t,!c.__c&&(c.__=[s?s(n):Cs(void 0,n),function(o){var f=c.__N?c.__N[0]:c.__[0],r=c.t(f,o);dn(f,r)||(c.__N=[r,c.__[1]],c.__c.setState({}))}],c.__c=Be,!Be.__f)){Be.__f=!0;var a=Be.shouldComponentUpdate;Be.shouldComponentUpdate=function(o,f,r){var g=this.__H;if(!g)return!0;var w=!1,d=this.props!=o;if(g.__.some(function(h){h.__N&&(w=!0,dn(h.__[0],h.__N[0])||(d=!0))}),a){var k=a.call(this,o,f,r);return w?k||d:k}return!w||d}}return c.__}function K(t,n){var s=fn(gt++,3);!Fe.__s&&ks(s.__H,n)&&(s.__P=!0,s.__=t,s.u=n,Be.__H.__h.push(s))}function ee(t){return Dt=5,Ke(function(){return{current:t}},[])}function Ke(t,n){var s=fn(gt++,7);return ks(s.__H,n)&&(s.__=t(),s.__H=n),s.__}function Jo(){var t;do{for(;t=un.shift();)try{bt(t)}catch(s){Fe.__e(s,{__:(t=t.__P)&&t.__v})}for(;t=ws.shift();){var n=t.__H;if(t.__P&&n)try{n.__h.some(bt),n.__h.some(pn),n.__h=[]}catch(s){n.__h=[],Fe.__e(s,t.__v)}}}while(un.length)}Fe.__b=function(t){Be=null,fs&&fs(t)},Fe.__=function(t,n){t&&n.__k&&n.__k.__m&&(t.__m=n.__k.__m),vs&&vs(t,n)},Fe.__r=function(t){ms&&ms(t),gt=0;var n=(Be=t.__c).__H;n&&(cn==Be?Be.__h=[]:(n.__h.some(bt),n.__h.some(pn),gt=0),n.__h=[],n.__.some(function(s){s.__N&&(s.__=s.__N),s.u=s.__N=void 0})),cn=Be},Fe.diffed=function(t){hs&&hs(t);var n=t.__c;n&&n.__H&&(n.__H.__h.length&&_s(ws.push(n)),n.__H.__.some(function(s){s.u&&(s.__H=s.u)})),cn=Be=null},Fe.__c=function(t,n){n.some(function(s){try{s.__h.some(bt),s.__h=s.__h.filter(function(c){return!c.__||pn(c)})}catch(c){n.some(function(a){a.__h&&(a.__h=[])}),n=[],Fe.__e(c,s.__v)}}),bs&&bs(t,n)},Fe.unmount=function(t){gs&&gs(t);var n,s,c=t.__c;c&&c.__H&&(c.__H.__.some(function(a){try{if(a.__P&&a.__c){if(s===void 0){for(s=t.__;s&&(!s.__c||!s.__c.__P);)s=s.__;s=s&&s.__c}a.__P=s,_s(un.push(a))}else bt(a)}catch(o){n=o}}),c.__H=void 0,n&&Fe.__e(n,c.__v))};var ys=typeof requestAnimationFrame=="function";function Xo(t){var n,s=function(){clearTimeout(c),ys&&cancelAnimationFrame(n),setTimeout(t)},c=setTimeout(s,35);ys&&(n=requestAnimationFrame(s))}function _s(t){t!=1&&ps==Fe.requestAnimationFrame||((ps=Fe.requestAnimationFrame)||Xo)(Jo)}function bt(t){var n=Be,s=t.__c;typeof s=="function"&&(t.__c=void 0,s()),Be=n}function pn(t){var n=Be;t.__c=t.__(),Be=n}function ks(t,n){return!t||t.length!=n.length||n.some(function(s,c){return!dn(s,t[c])})}function Cs(t,n){return typeof n=="function"?n(t):n}var Zo=0;function e(t,n,s,c,a,o){n||(n={});var f,r,g=n;if("ref"in g&&typeof t!="function")for(r in g={},n)r=="ref"?f=n[r]:g[r]=n[r];var w={type:t,props:g,key:s,ref:f,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--Zo,__i:-1,__u:0};return(a||o)&&(w.__source=a,w.__self=o),Ne.vnode&&Ne.vnode(w),w}var xt=null,At=[],Yo=1;function le(t,n=!1){let s={id:Yo++,message:t,error:n};xt?xt(s):At.push(s)}function Qo(){let[t,n]=l(null),s=ee(null);return K(()=>{let c=a=>{s.current!==null&&window.clearTimeout(s.current),n(a),s.current=window.setTimeout(()=>{n(null),s.current=null},3500)};if(xt=c,At.length>0){let a=At;At=[];for(let o of a)c(o)}return()=>{xt=null,s.current!==null&&(window.clearTimeout(s.current),s.current=null)}},[]),t?e("div",{class:t.error?"toast error":"toast",role:"status","aria-live":"polite",children:t.message}):null}function Ss(t){Oe(e(Qo,{}),t)}var Ye=class extends Error{constructor(n,s){super(n),this.name="APIError",this.status=s}},Is=[1e3,2e3,4e3,1e4],Vt=Is.length+1,ei=new Set([429,503]),ti=new Set([408,425,429,500,502,503,504]),Ts=new Set(["GET","HEAD","PUT","DELETE","OPTIONS"]),Mt=new Map;function Ns(t){return(t.method||"GET").toUpperCase()}function Ps(){return typeof DOMException=="function"?new DOMException("The request was aborted","AbortError"):Object.assign(new Error("The request was aborted"),{name:"AbortError"})}function ni(t,n){return new Promise((s,c)=>{if(n?.aborted){c(Ps());return}let a=window.setTimeout(()=>{n?.removeEventListener("abort",o),s()},t),o=()=>{window.clearTimeout(a),c(Ps())};n?.addEventListener("abort",o,{once:!0})})}function si(t,n,s){return s?.aborted?!1:n.status===void 0?Ts.has(t):ei.has(n.status)?!0:Ts.has(t)&&ti.has(n.status)}function Es(t,n,s){let c=s.status===void 0?s.message||"network error":`${s.message||"request failed"} (HTTP ${s.status})`;return new Ye(`${t} ${n} failed after ${Vt} attempts: ${c}`,s.status)}async function ai(t,n){let s=new Headers(n.headers||{});n.body!==void 0&&s.set("Content-Type","application/json");let c;try{c=await fetch(t,{...n,headers:s})}catch(o){throw o instanceof Error&&o.name==="AbortError"?o:new Ye(o instanceof Error?o.message:String(o))}let a=null;if(c.status!==204){let o=await c.text();if(o)try{a=JSON.parse(o)}catch{a=null}}if(!c.ok){let o=`Request failed (${c.status})`;throw a&&typeof a=="object"&&("error"in a&&typeof a.error=="string"?o=a.error:"message"in a&&typeof a.message=="string"&&(o=a.message)),new Ye(o,c.status)}return a}async function Ls(t,n){let s=Ns(n),c;for(let a=1;a<=Vt;a+=1)try{return await ai(t,n)}catch(o){if(!(o instanceof Ye)||(c=o,!si(s,o,n.signal)))throw o;if(a>=Vt)throw Es(s,t,o);let f=Is[a-1];console.warn(`${s} ${t} failed (attempt ${a}/${Vt}); retrying in ${f/1e3}s: ${o.message}`),await ni(f,n.signal)}throw Es(s,t,c??new Ye("request failed"))}function M(t,n={}){if(Ns(n)!=="GET"||n.signal)return Ls(t,n);let c=`GET ${t}`,a=Mt.get(c);if(a)return a;let o=Ls(t,n).finally(()=>{Mt.get(c)===o&&Mt.delete(c)});return Mt.set(c,o),o}function oi(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function mn(t,n){let s=new Blob([n],{type:"application/x-pem-file"}),c=URL.createObjectURL(s),a=document.createElement("a");a.href=c,a.download=t,a.click(),URL.revokeObjectURL(c)}function Ds(){let t=ee(null),[n,s]=l(!1),[c,a]=l(!0),[o,f]=l([]),[r,g]=l(""),[w,d]=l("365"),[k,h]=l(null),[L,T]=l(""),[m,y]=l(!1),[C,E]=l(!1);K(()=>{if(!n){t.current?.close();return}t.current?.showModal(),h(null),T(""),P()},[n]);let P=async()=>{y(!0);try{let _=await fetch("/api/pki/clients",{headers:{Accept:"application/json"}}),R=await _.text(),H=null;if(R)try{H=JSON.parse(R)}catch{H=null}if(!_.ok){let G=H&&typeof H=="object"&&"error"in H&&typeof H.error=="string"?H.error:`Request failed (${_.status})`;throw new Ye(G,_.status)}f(Array.isArray(H)?H:[]),a(!0),T("")}catch(_){_ instanceof Ye&&_.status===503?(a(!1),T("mTLS is not enabled on this server. Start rookery with -mtls-addr and -pki-dir.")):T(_ instanceof Error?_.message:String(_))}finally{y(!1)}},A=async _=>{if(_.preventDefault(),!(C||!r.trim())){E(!0),T("");try{let R=await M("/api/pki/clients",{method:"POST",body:JSON.stringify({name:r.trim(),days:Number(w)||365})});h(R),g(""),await P(),le(`Issued client certificate for ${R.name}`)}catch(R){T(R instanceof Error?R.message:String(R))}finally{E(!1)}}},b=async _=>{if(window.confirm(`Revoke the certificate for ${_.name}? Existing connections will fail on the next handshake.`)){E(!0),T("");try{await M(`/api/pki/clients/${_.id}`,{method:"DELETE"}),await P(),le(`Revoked client certificate for ${_.name}`)}catch(R){T(R instanceof Error?R.message:String(R))}finally{E(!1)}}};return e("div",{class:"sidebar-settings",children:[e("p",{class:"eyebrow",children:"Access"}),e("button",{class:"tab",type:"button",onClick:()=>s(!0),children:"Client certificates"}),e("span",{class:"field-hint",children:"Public mTLS API access"}),e("dialog",{ref:t,class:"dialog dialog-wide","aria-labelledby":"client-certs-title",onClose:()=>s(!1),onClick:_=>{_.target===_.currentTarget&&s(!1)},onCancel:_=>{C&&_.preventDefault()},children:e("div",{class:"dialog-body",children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"mTLS access"}),e("h2",{id:"client-certs-title",children:"Client certificates"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>s(!1),children:"\xD7"})]}),c?e(ye,{children:[e("p",{class:"field-hint",children:"Clients connect to the public mTLS listener and must present a certificate signed by this server's CA. Private keys are shown once and never stored by Rookery."}),e("div",{class:"actions",children:e("a",{class:"button ghost",href:"/api/pki/ca",download:"rookery-ca.pem",children:"Download CA certificate"})}),e("form",{class:"form-grid",onSubmit:_=>{A(_)},children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Client name"}),e("input",{type:"text",required:!0,maxlength:64,autocomplete:"off",spellcheck:!1,placeholder:"ops-laptop",value:r,onInput:_=>g(_.currentTarget.value)}),e("span",{class:"field-hint",children:"Used as the certificate common name."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Validity (days)"}),e("input",{type:"number",required:!0,min:1,max:3650,value:w,onInput:_=>d(_.currentTarget.value)})]}),e("div",{class:"field field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:C||!r.trim(),children:C?"Issuing\u2026":"Issue certificate"})})]})]}):e("div",{class:"banner",role:"alert",children:L||"mTLS is not enabled on this server."}),k?e("section",{class:"panel panel-spaced","aria-labelledby":"issued-cert-title",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h3",{id:"issued-cert-title",children:["Certificate for ",k.name]}),e("p",{class:"panel-subtitle",children:"Download or copy these files now; the private key will not be shown again."})]})}),e("div",{class:"actions",children:[e("button",{class:"button primary",type:"button",onClick:()=>mn("client.crt",k.certificate_pem),children:"Download client.crt"}),e("button",{class:"button primary",type:"button",onClick:()=>mn("client.key",k.private_key_pem),children:"Download client.key"}),e("button",{class:"button ghost",type:"button",onClick:()=>mn("ca.crt",k.ca_pem),children:"Download ca.crt"})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Client certificate (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:5,value:k.certificate_pem})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Client private key (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:5,value:k.private_key_pem})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CA certificate (PEM)"}),e("textarea",{class:"cert-pem",readOnly:!0,rows:4,value:k.ca_pem})]})]}):null,L&&c?e("p",{class:"form-error",role:"alert",children:L}):null,c?e("div",{class:"table-wrap",children:m?e("p",{class:"field-hint",children:"Loading issued certificates\u2026"}):o.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Serial"}),e("th",{scope:"col",children:"Expires"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col"})]})}),e("tbody",{children:o.map(_=>e("tr",{children:[e("td",{children:_.name}),e("td",{class:"mono",children:[_.serial.slice(0,16),"\u2026"]}),e("td",{children:oi(_.not_after)}),e("td",{children:e("span",{class:"status-badge","data-status":_.revoked?"disabled":"running",children:_.revoked?"revoked":"valid"})}),e("td",{children:_.revoked?null:e("button",{class:"row-action danger",type:"button",disabled:C,onClick:()=>{b(_)},children:"Revoke"})})]},_.id))})]}):e("p",{class:"field-hint",children:"No client certificates have been issued yet."})}):null,e("footer",{class:"dialog-actions",children:e("button",{class:"button ghost",type:"button",onClick:()=>s(!1),children:"Close"})})]})})]})}var vt="rookery:route-changed";function $t(){let t=window.location.pathname.replace(/\/+$/,"")||"/";return{path:t,segments:t.split("/").filter(Boolean),query:new URLSearchParams(window.location.search),search:window.location.search}}function te(t,n=!1){let s=t.startsWith("/")?t:`/${t}`;`${window.location.pathname}${window.location.search}`!==s&&(n?window.history.replaceState(null,"",s):window.history.pushState(null,"",s),window.dispatchEvent(new CustomEvent(vt)))}function je(){let[t,n]=l($t);return K(()=>{let s=()=>n($t());return window.addEventListener("popstate",s),window.addEventListener(vt,s),()=>{window.removeEventListener("popstate",s),window.removeEventListener(vt,s)}},[]),t}var Qe=["vms","disks","media","networks","vnfs","lightvm","settings"],ii={vms:{eyebrow:"Thin-LVM inventory",title:"Virtual machines",lede:"Create instances, attach disks and installation media, start them with QEMU/KVM, and open their consoles."},disks:{eyebrow:"Thin-LVM inventory",title:"Disks",lede:"Create and manage instance disks separately, then attach an unattached disk to a VM."},media:{eyebrow:"Installation media",title:"Installation media",lede:"Register read-only ISO files from the host, then choose one as a VM's boot media."},networks:{eyebrow:"VPP bridge domains",title:"Network",lede:"Create VPP bridge domains and vhost-user interfaces, then attach them to stopped VMs."},vnfs:{eyebrow:"Network functions",title:"VNFs",lede:"Attach DHCPv4 and DHCPv6 functions to Rookery Networks, enable them, and inspect their leases."},lightvm:{eyebrow:"LightVM inventory",title:"LightVM",lede:"Upload kernels and initramfs images, then define container-backed microVMs."},settings:{eyebrow:"Rookery server",title:"Settings",lede:"Configure cgroup resource limits, server-wide Rookery behavior, and lifecycle hooks."}};function Ce(t){let n=document.getElementById(t);if(!n)throw new Error(`missing UI element #${t}`);return n}function As(){let t=Ce("api-status"),n=Ce("api-status-label"),s=Ce("hero-eyebrow"),c=Ce("hero-title"),a=Ce("hero-lede"),o=Ce("readiness-summary"),f=Ce("thin-pool-card"),r=Ce("thin-pool-state"),g=Ce("thin-pool-target"),w=Ce("thin-pool-detail"),d=Ce("vpp-card"),k=Ce("vpp-state"),h=Ce("vpp-target"),L=Ce("vpp-detail"),T={card:Ce("rookery-startup-script-card"),state:Ce("rookery-startup-script-state"),target:Ce("rookery-startup-script-target"),detail:Ce("rookery-startup-script-detail")},m={card:Ce("vpp-startup-script-card"),state:Ce("vpp-startup-script-state"),target:Ce("vpp-startup-script-target"),detail:Ce("vpp-startup-script-detail")},y={vms:Ce("tab-vms"),disks:Ce("tab-disks"),media:Ce("tab-media"),networks:Ce("tab-networks"),vnfs:Ce("tab-vnfs"),lightvm:Ce("tab-lightvm"),settings:Ce("tab-settings")},C={vms:Ce("vms-panel"),disks:Ce("disks-panel"),media:Ce("media-panel"),networks:Ce("networks-panel"),vnfs:Ce("vnfs-panel"),lightvm:Ce("lightvm-panel"),settings:Ce("settings-panel")},E={vms:Ce("new-vm"),disks:Ce("new-disk"),media:Ce("new-media"),networks:Ce("new-network")},P="vms",A={ready:"ready",checking:"checking",missing:"missing",not_configured:"not configured",unreachable:"unreachable",error:"error"},b=p=>{let N={online:"api online",offline:"api offline",pending:"connecting"};t.dataset.state=p,n.textContent=N[p]},_=(p,N,X,ae,$,S)=>{let v=$?.state||"checking";p.dataset.state=v,N.textContent=A[v]||v,X.textContent=S,X.title=S;let V=$?.detail||"";!V&&v==="checking"&&(V="Checking\u2026"),ae.textContent=V},R=p=>{if(!p)return"";let N=new Date(p);return Number.isNaN(N.getTime())?"":N.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})},H=(p,N,X)=>{let ae="unreachable",$="unavailable",S="status unavailable",v="Could not read the startup script status.";if(N){let V=N.status||(N.enabled?"pending":"disabled"),ie=R(N.ran_at);switch(V){case"success":ae="ready",$="success",S=ie?`last run ${ie}`:"last run succeeded",v=`exit code ${N.exit_code??0}`;break;case"failed":ae="error",$="failed",S=ie?`last run ${ie}`:"last run failed",v=`exit code ${N.exit_code??"\u2014"} \xB7 open Settings for output`;break;case"running":ae="checking",$="running",S="running now",v=ie?`started ${ie}`:"Waiting for completion\u2026";break;case"pending":ae="missing",$="pending",S="not run yet",v=X==="vpp"?"Runs on the next VPP connection":"Runs on the next API start";break;case"disabled":ae="not_configured",$="disabled",S="disabled",v="Enable it in Settings";break;case"unavailable":ae="unreachable",$="unavailable",S="status unavailable",v="Could not read the startup script status.";break}}p.card.dataset.state=ae,p.state.textContent=$,p.target.textContent=S,p.target.title=S,p.detail.textContent=v},G=p=>{let N=!!p.ready,X=p.thin_pool,ae=p.vpp,$="not configured";if(X?.vg&&X.name?$=`${X.vg}/${X.name}`:X?.vg&&($=X.vg),_(f,r,g,w,X,$),_(d,k,h,L,ae,ae?.socket||"not configured"),H(T,p.rookery_startup_script,"rookery"),H(m,p.vpp_startup_script,"vpp"),N)o.dataset.state="ready",o.textContent="ready for VMs";else{let V=[X?.state,ae?.state].filter(Boolean);V.includes("unreachable")||V.includes("error")?o.dataset.state="unreachable":V.includes("missing")||V.includes("not_configured")?o.dataset.state="missing":o.dataset.state="checking",o.textContent=o.dataset.state==="checking"?"checking":"VM creation blocked"}let S=!!X?.ready,v=!!ae?.ready;window.dispatchEvent(new CustomEvent("rookery:readiness-changed",{detail:{ready:N,thinPool:S}})),window.dispatchEvent(new CustomEvent("rookery:vpp-ready",{detail:v}))},re=()=>{_(f,r,g,w,{state:"unreachable",detail:"Cannot reach the API."},"status unavailable"),_(d,k,h,L,{state:"unreachable",detail:"Cannot reach the API."},"status unavailable"),H(T,void 0,"rookery"),H(m,void 0,"vpp"),o.dataset.state="unreachable",o.textContent="status unavailable",window.dispatchEvent(new CustomEvent("rookery:readiness-changed",{detail:{ready:!1,thinPool:!1}})),window.dispatchEvent(new CustomEvent("rookery:vpp-ready",{detail:!1}))},x=async()=>{try{let p=await M("/api/status");G(p),b("online")}catch{b("offline"),re()}},F=p=>Qe.includes(p),B=p=>{let N=p.split("/").filter(Boolean)[0]||"vms";return N==="storage-backends"?"disks":N==="interfaces"||N==="acls"||N==="route-tables"?"networks":F(N)?N:"vms"},z=(p,N=!1,X=!0)=>{let ae=F(p)?p:"vms",$=ae!==P;P=ae;for(let v of Qe){let V=v===ae;y[v].setAttribute("aria-selected",String(V)),y[v].tabIndex=V?0:-1,C[v].hidden=!V;let ie=E[v];ie&&(ie.hidden=!V)}X&&te(`/${ae}`);let S=ii[ae];s.textContent=S.eyebrow,c.textContent=S.title,a.textContent=S.lede,document.title=`${S.title} \xB7 rookery`,$&&window.dispatchEvent(new CustomEvent("rookery:tab-changed",{detail:ae})),N&&y[ae].focus()},J=p=>{if(!["ArrowLeft","ArrowRight","Home","End"].includes(p.key))return;p.preventDefault();let X=Qe.indexOf(P);p.key==="Home"?z(Qe[0],!0):p.key==="End"?z(Qe[Qe.length-1],!0):p.key==="ArrowLeft"?z(Qe[(X-1+Qe.length)%Qe.length],!0):z(Qe[(X+1)%Qe.length],!0)},se=()=>{let p=$t();p.path==="/"&&window.history.replaceState(null,"","/vms");let N=p.path.startsWith("/route-tables")||p.path==="/vnfs/new"||/^\/vnfs\/[^/]+\/edit$/.test(p.path);document.body.classList.toggle("route-page-mode",N),z(B(p.path),!1,!1)};for(let p of Qe)y[p].addEventListener("click",()=>z(p)),y[p].addEventListener("keydown",J);E.vms?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-vm"))}),E.disks?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-disk"))}),E.media?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-media"))}),E.networks?.addEventListener("click",()=>{window.dispatchEvent(new CustomEvent("rookery:add-network"))}),window.addEventListener("popstate",se),window.addEventListener(vt,se),window.addEventListener("rookery:open-tab",p=>{let N=String(p.detail||"");F(N)&&z(N)}),window.setInterval(()=>{document.hidden||x()},1e4);let ge=window.location.hash.replace(/^#/,"");ge&&F(ge)&&window.history.replaceState(null,"",`/${ge}`),se(),x()}var Rt=64;function Me(t){let[n,s]=l({}),c=async()=>{try{let o=await M(`/api/labels/${encodeURIComponent(t)}`),f={};for(let r of Array.isArray(o)?o:[])f[r.entity_id]=Array.isArray(r.labels)?r.labels:[];s(f)}catch{s({})}};return K(()=>{c()},[t]),K(()=>{let o=f=>{let r=f.detail;!r||r.entityType!==t||!r.entityID||s(g=>({...g,[r.entityID]:Array.isArray(r.labels)?r.labels:[]}))};return window.addEventListener("rookery:labels-changed",o),()=>window.removeEventListener("rookery:labels-changed",o)},[t]),{labels:n,reload:c,setEntityLabels:(o,f)=>{s(r=>({...r,[o]:f}))}}}function Pe(t,n){let[s,c]=l([]);return K(()=>{if(n==null||String(n)===""){c([]);return}let a=!0;return M(`/api/labels/${encodeURIComponent(t)}/${encodeURIComponent(String(n))}`).then(o=>{a&&c(Array.isArray(o.labels)?o.labels:[])}).catch(()=>{a&&c([])}),()=>{a=!1}},[t,n]),[s,c]}async function xs(t,n,s){let c=await M(`/api/labels/${encodeURIComponent(t)}/${encodeURIComponent(String(n))}`,{method:"PUT",body:JSON.stringify({labels:s})}),a=Array.isArray(c.labels)?c.labels:[];return window.dispatchEvent(new CustomEvent("rookery:labels-changed",{detail:{entityType:t,entityID:String(n),labels:a}})),a}async function Ee(t,n,s,c){try{await xs(t,n,c)}catch(a){le(`Saved ${s}, but labels could not be updated: ${a instanceof Error?a.message:String(a)}`,!0)}}function Le({labels:t,onChange:n,disabled:s=!1}){let[c,a]=l(""),o=Array.isArray(t)?t:[],f=()=>{let r=c.split(/[\n,]+/).map(w=>w.trim()).filter(Boolean);if(r.length===0)return;let g=[...o];for(let w of r)g.includes(w)||g.push(w);n(g),a("")};return e("div",{class:"labels-field",children:[e("span",{class:"field-label",children:"Labels"}),e("div",{class:"labels-editor-list",children:o.length?o.map(r=>e("span",{class:"label-chip editable",children:[r,e("button",{type:"button","aria-label":`Remove ${r}`,disabled:s,onClick:()=>n(o.filter(g=>g!==r)),children:"\xD7"})]},r)):e("span",{class:"field-hint",children:"No labels yet."})}),e("div",{class:"labels-editor-input",children:[e("input",{type:"text",value:c,maxLength:256,disabled:s||o.length>=Rt,placeholder:"production, web, critical",onInput:r=>a(r.currentTarget.value),onKeyDown:r=>{r.key==="Enter"&&(r.preventDefault(),f())}}),e("button",{class:"button ghost",type:"button",disabled:s||!c.trim()||o.length>=Rt,onClick:f,children:"Add"})]}),e("span",{class:"field-hint",children:["Add as many labels as you need. Commas separate multiple labels; up to ",Rt,"."]})]})}function Ve({entityType:t,entityID:n,name:s,labels:c,onSaved:a}){let[o,f]=l(!1),r=Array.isArray(c)?c:[];return e("div",{class:"entity-labels",children:[r.map(g=>e("span",{class:"label-chip",title:g,children:g},g)),e("button",{class:"label-add",type:"button","aria-label":`Edit labels for ${s}`,onClick:()=>f(!0),children:r.length?"edit labels":"+ label"}),o?e(ri,{entityType:t,entityID:String(n),name:s,labels:r,onClose:()=>f(!1),onSaved:g=>{a(g),f(!1)}}):null]})}function ri({entityType:t,entityID:n,name:s,labels:c,onClose:a,onSaved:o}){let f=ee(null),r=ee(null),[g,w]=l(c),[d,k]=l(""),[h,L]=l(""),[T,m]=l(!1);K(()=>{f.current?.showModal(),r.current?.focus()},[]);let y=()=>{T||f.current?.close()},C=()=>{let P=d.split(/[\n,]+/).map(A=>A.trim()).filter(Boolean);P.length!==0&&(w(A=>{let b=[...A];for(let _ of P)b.includes(_)||b.push(_);return b}),k(""),L(""))},E=async P=>{if(P.preventDefault(),!T){m(!0),L("");try{let A=await xs(t,n,g);o(A),le(`Saved labels for ${s}`)}catch(A){L(A instanceof Error?A.message:String(A))}finally{m(!1)}}};return e("dialog",{ref:f,class:"dialog labels-dialog","aria-labelledby":"labels-dialog-title",onClose:a,onClick:P=>{P.target===P.currentTarget&&y()},onCancel:P=>{T&&P.preventDefault()},children:e("form",{onSubmit:P=>{E(P)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Labels"}),e("h2",{id:"labels-dialog-title",children:s})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:y,children:"\xD7"})]}),e("div",{class:"labels-editor",children:[e("span",{class:"field-hint",children:["Add as many labels as you need. Labels are free-form text; commas separate multiple labels. Up to ",Rt," per ",t,"."]}),e("div",{class:"labels-editor-list",children:g.length?g.map(P=>e("span",{class:"label-chip editable",children:[P,e("button",{type:"button","aria-label":`Remove ${P}`,disabled:T,onClick:()=>w(A=>A.filter(b=>b!==P)),children:"\xD7"})]},P)):e("span",{class:"field-hint",children:"No labels yet."})}),e("div",{class:"labels-editor-input",children:[e("input",{ref:r,type:"text",value:d,maxLength:256,placeholder:"production, web, critical",onInput:P=>k(P.currentTarget.value),onKeyDown:P=>{P.key==="Enter"&&(P.preventDefault(),C())}}),e("button",{class:"button ghost",type:"button",disabled:!d.trim()||T,onClick:C,children:"Add"})]}),h?e("p",{class:"form-error",role:"alert",children:h}):null]}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:T,onClick:y,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:T,children:T?"Saving\u2026":"Save labels"})]})]})})}var li="[A-Za-z0-9][A-Za-z0-9._\\-]*";function $s({backends:t,disks:n,onClose:s,onSaved:c}){let a=t.find(q=>q.name==="default")||t[0],[o,f]=l(t.length===0?"host":"lvm"),[r,g]=l(""),[w,d]=l("10"),[k,h]=l("0"),[L,T]=l("0"),[m,y]=l(a?String(a.id):""),[C,E]=l([]),[P,A]=l(""),[b,_]=l(!1),[R,H]=l(""),[G,re]=l("empty"),[x,F]=l(""),[B,z]=l(!1),[J,se]=l(""),[ge,p]=l("5m"),[N,X]=l("{}"),[ae,$]=Pe("disk"),[S,v]=l(!1),[V,ie]=l(""),pe=ee(null),fe=ee(null),ne=ee(null);K(()=>{pe.current?.showModal(),fe.current?.focus()},[]);let me=t.find(q=>String(q.id)===m)||null,ue=!!me?.config?.supports_templates,u=n.filter(q=>q.is_template&&String(q.storage_backend_id??"")===m),I=u.find(q=>String(q.id)===x)||null,U=C.find(q=>q.path===P)||null;K(()=>{ue||(re("empty"),F(""),z(!1))},[ue]),K(()=>{if(o!=="host")return;let q=!1;return _(!0),H(""),M("/api/host/block-devices/attachable").then(_e=>{q||E(Array.isArray(_e)?_e:[])}).catch(_e=>{q||(E([]),H(_e instanceof Error?_e.message:String(_e)))}).finally(()=>{q||_(!1)}),()=>{q=!0}},[o]);let Q=()=>{S||pe.current?.close()},Z=async q=>{if(q.preventDefault(),S||!ne.current?.reportValidity())return;if(o==="host"&&(!U||!U.available)){ie("Select an available host block device.");return}let _e;if(o==="lvm"&&G==="template"&&I?.provisioning_script){try{_e=JSON.parse(N||"{}")}catch{ie("Provisioning properties must be valid JSON.");return}if(_e===null||Array.isArray(_e)||typeof _e!="object"){ie("Provisioning properties must be a JSON object.");return}}ie(""),v(!0);try{let oe=o==="host"?{name:r.trim(),host_device:U?.path,read_bps:Number(k)||0,write_bps:Number(L)||0}:{name:r.trim(),size_gb:Number(w),storage_backend_id:Number(m),read_bps:Number(k)||0,write_bps:Number(L)||0,...G==="template"&&I?{template_disk_id:I.id}:{},...G==="template"&&I?.provisioning_script&&_e!==void 0?{provisioning:_e}:{},...G==="empty"&&ue?{is_template:B,...B?{provisioning_script:J.trim(),provisioning_timeout:ge.trim()}:{}}:{}},he=await M("/api/disks",{method:"POST",body:JSON.stringify(oe)});await Ee("disk",he.id,he.name,ae),c(he)}catch(oe){ie(oe instanceof Error?oe.message:String(oe))}finally{v(!1)}};return e("dialog",{ref:pe,id:"disk-dialog","aria-labelledby":"disk-dialog-title",onClose:s,onClick:q=>{q.target===q.currentTarget&&Q()},onCancel:q=>{S&&q.preventDefault()},children:e("form",{ref:ne,novalidate:!0,onSubmit:q=>{Z(q)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"New disk"}),e("h2",{id:"disk-dialog-title",children:"Create disk"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:Q,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:fe,type:"text",required:!0,maxlength:127,pattern:li,autocomplete:"off",spellcheck:!1,placeholder:"vm01-root",value:r,onInput:q=>g(q.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Storage source"}),e("select",{value:o,onChange:q=>f(q.currentTarget.value),children:[e("option",{value:"lvm",disabled:t.length===0,children:"Provisioned storage (LVM thin or kouja)"}),e("option",{value:"host",children:"Host block device (passthrough)"})]}),e("span",{class:"field-hint",children:"Provisioned disks use the selected storage backend. Passthrough disks hand an existing host disk, partition, or logical volume to one VM; Rookery never deletes or resizes the underlying device."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Read bandwidth limit (bytes/s)"}),e("input",{type:"number",min:0,step:1,placeholder:"0 (unlimited)",value:k,onInput:q=>h(q.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Write bandwidth limit (bytes/s)"}),e("input",{type:"number",min:0,step:1,placeholder:"0 (unlimited)",value:L,onInput:q=>T(q.currentTarget.value)}),e("span",{class:"field-hint",children:"QEMU block throttling; 0 means unlimited. Applies when the VM starts."})]}),o==="lvm"?e(ye,{children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Size (GB)"}),e("input",{type:"number",required:!0,min:I?I.size_gb:1,max:65536,step:1,value:w,onInput:q=>d(q.currentTarget.value)}),e("span",{class:"field-hint",children:"Creates an LVM thin volume of this size in the configured pool."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Storage backend"}),e("select",{required:!0,value:m,onChange:q=>{y(q.currentTarget.value),F(""),re("empty")},children:t.map(q=>e("option",{value:String(q.id),children:[q.name," \xB7 ",q.driver==="lvm-thin"?"LVM thin":q.driver==="kouja"?"Kouja":q.driver]},q.id))}),e("span",{class:"field-hint",children:me?.driver==="kouja"?"Kouja provisions a remote volume and exports it over the selected protocol.":ue?"Template cloning is available on this backend.":"Template cloning is disabled for this backend; enable it in Storage backends."})]}),ue?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Creation source"}),e("select",{value:G,onChange:q=>re(q.currentTarget.value),children:[e("option",{value:"empty",children:"Empty disk"}),e("option",{value:"template",disabled:u.length===0,children:["From template",u.length===0?" (none available)":""]})]}),e("span",{class:"field-hint",children:"Template clones run the backend preparation script and the template's provisioning script when one is configured; empty disks use the thin pool directly."})]}):null,ue&&G==="template"?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Template"}),e("select",{required:!0,value:x,onChange:q=>{let _e=q.currentTarget.value;F(_e);let oe=u.find(he=>String(he.id)===_e);oe&&d(String(oe.size_gb))},children:[e("option",{value:"",children:"Select a template\u2026"}),u.map(q=>e("option",{value:String(q.id),children:[q.name," \xB7 ",q.size_gb," GB",q.provisioning_script?" \xB7 provisioning":""]},q.id))]})]}):null,ue&&G==="empty"?e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:B,onChange:q=>z(q.currentTarget.checked)}),e("span",{children:"Mark this new disk as a template"})]})}):null,ue&&G==="empty"&&B?e(ye,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"VM provisioning script"}),e("input",{type:"text",placeholder:"/usr/local/sbin/rookery-provision-template",value:J,onInput:q=>se(q.currentTarget.value)}),e("span",{class:"field-hint",children:["Optional absolute-path script run after a clone is created. It receives",e("code",{children:"ROOKERY_PROVISION_JSON"}),"."]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Provisioning timeout"}),e("input",{type:"text",placeholder:"5m",value:ge,onInput:q=>p(q.currentTarget.value)})]})]}):null,ue&&G==="template"&&I&&!I.provisioning_script?e("p",{class:"field-hint field-wide",children:["This template has no provisioning script, so there are no key-values to pass. Close this dialog and use ",e("strong",{children:"Provisioning"})," on the template row to configure one first."]}):null,ue&&G==="template"&&I?.provisioning_script?e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Provisioning properties (JSON object)"}),e("textarea",{rows:6,spellcheck:!1,value:N,onInput:q=>X(q.currentTarget.value)}),e("span",{class:"field-hint",children:["Passed to the template provisioning script through",e("code",{children:"ROOKERY_PROVISION_JSON"})," under ",e("code",{children:"properties"}),". Set ",e("code",{children:'"resize_disk": true'})," to extend the root partition and filesystem with the reference script. Leave",e("code",{})," empty to skip provisioning."]})]}):null]}):e(ye,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Host block device"}),e("select",{required:!0,value:P,disabled:b,onChange:q=>{let _e=q.currentTarget.value;A(_e);let oe=C.find(he=>he.path===_e);oe&&!r.trim()&&g(ci(oe))},children:[e("option",{value:"",children:b?"Loading devices\u2026":"Select a host block device\u2026"}),C.map(q=>e("option",{value:q.path,disabled:!q.available,children:[Ms(q.kind)," \xB7 ",q.path," \xB7 ",Vs(q.size_bytes),q.available?"":` \xB7 ${q.unavailable_reason||"unavailable"}`]},`${q.kind}:${q.path}`))]}),e("span",{class:"field-hint",children:"Whole disks, partitions, and logical volumes are listed. Mounted, swap, and otherwise busy devices are disabled."})]}),R?e("p",{class:"form-error field-wide",role:"alert",children:R}):null,U?e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Selected device"}),e("p",{class:"field-hint",children:[Ms(U.kind)," \xB7 ",U.path," \xB7"," ",Vs(U.size_bytes),U.model?` \xB7 ${U.model}`:""]}),U.available?null:e("p",{class:"form-error",role:"alert",children:U.unavailable_reason||"This device is not available."})]}):null,C.length===0&&!b&&!R?e("p",{class:"field-hint field-wide",children:"No host block devices were found."}):null]}),e("div",{class:"field field-wide",children:e(Le,{labels:ae,onChange:$,disabled:S})})]}),V?e("p",{class:"form-error",role:"alert",children:V}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:S,onClick:Q,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:S||(o==="lvm"?t.length===0||G==="template"&&!I:!U||!U.available),children:S?"Creating\u2026":"Create disk"})]})]})})}function Ms(t){switch(t){case"disk":return"Disk";case"partition":return"Partition";case"lv":return"Logical volume";case"raid":return"RAID array";default:return"Block device"}}function Vs(t){let n=Number(t)||0;return n>=1024**4?`${(n/1024**4).toFixed(n%1024**4===0?0:1)} TB`:n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function ci(t){return(t.path.split("/").filter(Boolean).pop()||t.name).replace(/[^A-Za-z0-9._-]+/g,"-").replace(/^[^A-Za-z0-9]+/,"").slice(0,127)||t.name}function Rs({disk:t,onClose:n,onSaved:s}){let[c,a]=l(String(t.read_bps??0)),[o,f]=l(String(t.write_bps??0)),[r,g]=l(!1),[w,d]=l(""),k=ee(null);K(()=>{k.current?.showModal()},[]);let h=()=>{r||k.current?.close()},L=async T=>{if(T.preventDefault(),r)return;let m=Number(c)||0,y=Number(o)||0;if(m<0||y<0){d("Bandwidth limits must be zero or greater.");return}g(!0),d("");try{let C=await M(`/api/disks/${t.id}`,{method:"PUT",body:JSON.stringify({read_bps:m,write_bps:y})});s(C)}catch(C){d(C instanceof Error?C.message:String(C))}finally{g(!1)}};return e("dialog",{ref:k,class:"dialog","aria-labelledby":"disk-bandwidth-title",onClose:n,onClick:T=>{T.target===T.currentTarget&&h()},onCancel:T=>{r&&T.preventDefault()},children:e("form",{novalidate:!0,onSubmit:T=>{L(T)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Disk limits"}),e("h2",{id:"disk-bandwidth-title",children:t.name})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:h,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Read bandwidth limit (bytes/s)"}),e("input",{type:"number",min:0,step:1,value:c,onInput:T=>a(T.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Write bandwidth limit (bytes/s)"}),e("input",{type:"number",min:0,step:1,value:o,onInput:T=>f(T.currentTarget.value)})]}),e("p",{class:"field-hint field-wide",children:"0 means unlimited. QEMU applies these limits when the disk is attached to a VM start; a running VM keeps its current limits until it is restarted."}),w?e("p",{class:"form-error field-wide",role:"alert",children:w}):null]}),e("div",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:r,onClick:h,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:r,children:r?"Saving\u2026":"Save limits"})]})]})})}function Fs({backend:t,onClose:n,onImported:s}){let[c,a]=l([]),[o,f]=l(()=>new Set),[r,g]=l(!0),[w,d]=l(!1),[k,h]=l(""),L=ee(null),T=async()=>{g(!0),h("");try{let E=await M(`/api/storage-backends/${t.id}/importable-disks`);a(Array.isArray(E)?E:[]),f(new Set)}catch(E){a([]),h(E instanceof Error?E.message:String(E))}finally{g(!1)}};K(()=>{L.current?.showModal(),T()},[]);let m=()=>{w||L.current?.close()},y=(E,P)=>{f(A=>{let b=new Set(A);return P?b.add(E):b.delete(E),b})},C=async()=>{if(!(w||o.size===0)){d(!0),h("");try{let E=await M(`/api/storage-backends/${t.id}/import-disks`,{method:"POST",body:JSON.stringify({names:[...o]})});s(Array.isArray(E)?E:[])}catch(E){h(E instanceof Error?E.message:String(E))}finally{d(!1)}}};return e("dialog",{ref:L,id:"import-disk-dialog","aria-labelledby":"import-disk-dialog-title",onClose:n,onClick:E=>{E.target===E.currentTarget&&m()},onCancel:E=>{w&&E.preventDefault()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Storage backend"}),e("h2",{id:"import-disk-dialog-title",children:"Import existing disks"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("p",{class:"import-disk-lede",children:[t.name," \xB7 ",t.config.vg,"/",t.config.thin_pool," \u2014 select existing thin volumes to register as disks. Importing does not modify LVM, but deleting an imported disk later removes its volume."]}),k?e("div",{class:"banner",role:"alert",children:k}):null,e("div",{class:"import-disk-list",children:[r?e("p",{class:"import-disk-meta",children:"Loading existing thin volumes\u2026"}):null,!r&&c.length===0&&!k?e("div",{class:"empty import-disk-empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A3"}),e("h3",{children:"No thin volumes found"}),e("p",{children:"This storage backend has no existing thin volumes to import."})]}):null,r?null:c.map(E=>e("label",{class:E.registered?"import-disk-row imported":"import-disk-row",children:[e("input",{type:"checkbox",value:E.name,checked:o.has(E.name),disabled:E.registered||w,"aria-label":`Import ${E.name}`,onChange:P=>y(E.name,P.currentTarget.checked)}),e("span",{class:"import-disk-copy",children:[e("span",{class:"import-disk-name",children:E.name}),e("span",{class:"import-disk-meta",children:[E.size_gb," GB \xB7"," ",E.registered?E.attached_vm_name?`already imported \xB7 attached to ${E.attached_vm_name}`:E.storage_backend_name?`already imported \xB7 ${E.storage_backend_name}`:"already imported":"available to import"]})]}),e("span",{class:"import-disk-badge",children:E.registered?"imported":"new"})]},E.name))]}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:r||w,onClick:()=>{T()},children:r?"Refreshing\u2026":"Refresh"}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"button",disabled:w||o.size===0,onClick:()=>{C()},children:w?"Importing\u2026":o.size>0?`Import selected (${o.size})`:"Import selected"})]})]})}var di="[A-Za-z0-9][A-Za-z0-9._\\-]*";function Bs({backend:t,onClose:n,onSaved:s}){let c=t!==null,[a,o]=l(t?.name??""),[f,r]=l(t?.title??""),[g,w]=l(t?.description??""),[d,k]=l(t?.driver??"lvm-thin"),[h,L]=l(t?.config?.vg??""),[T,m]=l(t?.config?.thin_pool??""),[y,C]=l(!!t?.config?.supports_templates),[E,P]=l(t?.config?.preparation_script??""),[A,b]=l(t?.config?.preparation_timeout||"5m"),[_,R]=l(!!t?.config?.allow_template_delete_with_clones),[H,G]=l(t?.config?.kouja?.endpoint??""),[re,x]=l(t?.config?.kouja?.protocol??"iscsi"),[F,B]=l(t?.config?.kouja?.storage_class??""),[z,J]=l(t?.config?.kouja?.storage_class?[t.config.kouja.storage_class]:[]),[se,ge]=l(!1),[p,N]=l(""),[X,ae]=l(""),[$,S]=l(t?.config?.kouja?.initiator??""),[v,V]=l(t?.config?.kouja?.host_nqn??""),[ie]=l(t?.config?.kouja?.ca_file??""),[pe]=l(t?.config?.kouja?.client_cert_file??""),[fe]=l(t?.config?.kouja?.client_key_file??""),[ne,me]=l(t?.config?.kouja?.ca_pem??""),[ue,u]=l(t?.config?.kouja?.client_cert_pem??""),[I,U]=l(t?.config?.kouja?.client_key_pem??""),[Q,Z]=l(t?.config?.kouja?.server_name??""),[q,_e]=l(t?.config?.kouja?.portal??""),[oe,he]=Pe("storage_backend",t?.id),[ve,j]=l(!1),[$e,O]=l(""),we=ee(null),Te=ee(null),W=ee(null);K(()=>{we.current?.showModal(),Te.current?.focus()},[]);let ke=()=>{ve||we.current?.close()},Se=(ce,Re)=>{let Ie=ce.currentTarget.files?.[0];Ie&&Ie.text().then(Re)},De=()=>({endpoint:H.trim(),protocol:re,storage_class:F.trim(),initiator:$.trim(),host_nqn:v.trim(),ca_file:ie.trim(),client_cert_file:pe.trim(),client_key_file:fe.trim(),ca_pem:ne.trim(),client_cert_pem:ue.trim(),client_key_pem:I.trim(),server_name:Q.trim(),portal:q.trim()}),He=async()=>{if(!se){ge(!0),N(""),ae("");try{let ce=await M("/api/storage-backends/kouja/probe",{method:"POST",body:JSON.stringify({config:De()})}),Re=Array.from(new Set(ce.storage_classes??[]));J(Re),Re.length>0&&(!F||!Re.includes(F))&&B(Re[0]);let Ae=ce.protocols?.length?` \xB7 protocols: ${ce.protocols.join(", ")}`:"",Ie=ce.owner?` \xB7 ${ce.owner}`:"";N(`${ce.detail||"connected"}${Ie}${Ae}`),Re.length===0&&ae("Connected, but kouja returned no storage classes.")}catch(ce){ae(ce instanceof Error?ce.message:String(ce))}finally{ge(!1)}}},Xe=async ce=>{if(ce.preventDefault(),!(ve||!W.current?.reportValidity())){O(""),j(!0);try{let Re=await M(c?`/api/storage-backends/${t?.id}`:"/api/storage-backends",{method:c?"PUT":"POST",body:JSON.stringify({name:a.trim(),title:f.trim(),description:g.trim(),driver:d,config:d==="kouja"?{kouja:De()}:{vg:h.trim(),thin_pool:T.trim(),supports_templates:y,preparation_script:y?E.trim():"",preparation_timeout:y?A.trim()||"5m":"",allow_template_delete_with_clones:y&&_}})});await Ee("storage_backend",Re.id,Re.name,oe),s(Re)}catch(Re){O(Re instanceof Error?Re.message:String(Re))}finally{j(!1)}}};return e("dialog",{ref:we,id:"storage-backend-dialog","aria-labelledby":"storage-backend-dialog-title",onClose:n,onClick:ce=>{ce.target===ce.currentTarget&&ke()},onCancel:ce=>{ve&&ce.preventDefault()},children:e("form",{ref:W,novalidate:!0,onSubmit:ce=>{Xe(ce)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:c?"Edit backend":"New backend"}),e("h2",{id:"storage-backend-dialog-title",children:c?`Storage backend \xB7 ${t?.name}`:"Register storage backend"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:ke,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:Te,type:"text",required:!0,maxlength:64,pattern:di,autocomplete:"off",spellcheck:!1,placeholder:"fast-pool",value:a,onInput:ce=>o(ce.currentTarget.value)}),e("span",{class:"field-hint",children:"Used when creating disks."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Title"}),e("input",{type:"text",maxlength:128,autocomplete:"off",placeholder:"Fast NVMe thin pool",value:f,onInput:ce=>r(ce.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:2,maxlength:2e3,placeholder:"What this backend is for",value:g,onInput:ce=>w(ce.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Driver"}),e("select",{required:!0,value:d,onChange:ce=>k(ce.currentTarget.value),children:[e("option",{value:"lvm-thin",children:"LVM thin pool"}),e("option",{value:"kouja",children:"Kouja"})]}),e("span",{class:"field-hint",children:"Kouja provisions remote volumes over its agent API."})]}),d==="kouja"?e(ye,{children:[e("fieldset",{class:"connection-section",children:[e("legend",{children:"Endpoint"}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Kouja endpoint"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"10.10.0.1:9443",value:H,onInput:ce=>G(ce.currentTarget.value)}),e("span",{class:"field-hint",children:"TLS endpoint of the kouja agent."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Protocol"}),e("select",{value:re,onChange:ce=>x(ce.currentTarget.value),children:[e("option",{value:"iscsi",children:"iSCSI"}),e("option",{value:"nvme-tcp",disabled:!0,children:"NVMe/TCP (coming soon)"})]})]})]}),e("div",{class:"connection-actions",children:[e("button",{class:"button primary",type:"button",disabled:se,onClick:()=>{He()},children:se?"Connecting\u2026":"Connect and fetch storage classes"}),p?e("span",{class:"connection-status",children:p}):null,X?e("span",{class:"connection-status error",children:X}):null]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"TLS server name"}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["TLS server name ",e("em",{children:"optional"})]}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"kouja-storage-1",value:Q,onInput:ce=>Z(ce.currentTarget.value)}),e("span",{class:"field-hint",children:"Override the certificate hostname when the endpoint is an address."})]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"CA certificate"}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"CA certificate (PEM)"}),e("textarea",{class:"mono",rows:5,required:ie==="",spellcheck:!1,placeholder:`-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE-----`,value:ne,onInput:ce=>me(ce.currentTarget.value)}),e("input",{type:"file",accept:".pem,.crt,.cer",onChange:ce=>Se(ce,me)}),e("span",{class:"field-hint",children:"Paste the CA PEM or load it from a file. The content is stored with the backend."})]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"Client certificate"}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Client certificate (PEM)"}),e("textarea",{class:"mono",rows:5,required:pe==="",spellcheck:!1,placeholder:`-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE-----`,value:ue,onInput:ce=>u(ce.currentTarget.value)}),e("input",{type:"file",accept:".pem,.crt,.cer",onChange:ce=>Se(ce,u)}),e("span",{class:"field-hint",children:"The certificate must contain one spiffe://kouja/caller/ URI SAN."})]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"Client private key"}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Client private key (PEM)"}),e("textarea",{class:"mono",rows:5,required:fe==="",spellcheck:!1,placeholder:`-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY-----`,value:I,onInput:ce=>U(ce.currentTarget.value)}),e("input",{type:"file",accept:".pem,.key",onChange:ce=>Se(ce,U)}),e("span",{class:"field-hint",children:"The private key is stored in the Rookery database; protect the database and backups."})]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"Storage class"}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Storage class"}),e("select",{required:!0,value:F,onChange:ce=>B(ce.currentTarget.value),children:[z.length===0&&F===""?e("option",{value:"",disabled:!0,children:"Connect to load storage classes"}):null,z.map(ce=>e("option",{value:ce,children:ce},ce)),F!==""&&!z.includes(F)?e("option",{value:F,children:[F," (saved)"]}):null]}),e("span",{class:"field-hint",children:"Fetched from the kouja service after a successful connection."})]})]}),e("fieldset",{class:"connection-section",children:[e("legend",{children:"iSCSI target"}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Initiator IQN"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"iqn.2026-11.io.example:rookery",value:$,onInput:ce=>S(ce.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Portal ",e("em",{children:"optional"})]}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"10.10.0.1:3260",value:q,onInput:ce=>_e(ce.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["NVMe host NQN ",e("em",{children:"optional"})]}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"nqn.2026-11.io.example:rookery",value:v,onInput:ce=>V(ce.currentTarget.value)})]})]})]})]}):null,e("label",{class:"field",hidden:d==="kouja",children:[e("span",{class:"field-label",children:"Volume group"}),e("input",{type:"text",required:d!=="kouja",autocomplete:"off",spellcheck:!1,placeholder:"vg0",value:h,onInput:ce=>L(ce.currentTarget.value)})]}),e("label",{class:"field",hidden:d==="kouja",children:[e("span",{class:"field-label",children:"Thin pool"}),e("input",{type:"text",required:d!=="kouja",autocomplete:"off",spellcheck:!1,placeholder:"rookery",value:T,onInput:ce=>m(ce.currentTarget.value)})]}),e("label",{class:"field field-wide checkbox-field",hidden:d==="kouja",children:[e("span",{class:"field-label",children:"Templates"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:y,onChange:ce=>C(ce.currentTarget.checked)}),e("span",{children:"Support template disks and template-based disk creation"})]}),e("span",{class:"field-hint",children:"Clone existing template disks with the backend preparation script."})]}),y&&d!=="kouja"?e(ye,{children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Preparation script"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"/usr/local/sbin/rookery-disk-prepare",value:E,onInput:ce=>P(ce.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path. Runs as the API user with disk details in ROOKERY_DISK_JSON."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Script timeout"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"5m",value:A,onInput:ce=>b(ce.currentTarget.value)})]}),e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:_,onChange:ce=>R(ce.currentTarget.checked)}),e("span",{children:"Allow deleting a template while clones still exist"})]})})]}):null,e("div",{class:"field field-wide",children:e(Le,{labels:oe,onChange:he,disabled:ve})})]}),$e?e("p",{class:"form-error",role:"alert",children:$e}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:ve,onClick:ke,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:ve,children:ve?"Saving\u2026":"Save backend"})]})]})})}function Hs({disk:t,onClose:n,onSaved:s}){let[c,a]=l(t.provisioning_script||""),[o,f]=l(t.provisioning_timeout||""),[r,g]=l(!1),[w,d]=l(""),k=ee(null);K(()=>{k.current?.showModal()},[]);let h=()=>{r||k.current?.close()},L=async T=>{if(T.preventDefault(),!r){g(!0),d("");try{let m=await M(`/api/disks/${t.id}`,{method:"PUT",body:JSON.stringify({provisioning_script:c.trim(),provisioning_timeout:o.trim()})});s(m)}catch(m){d(m instanceof Error?m.message:String(m))}finally{g(!1)}}};return e("dialog",{ref:k,id:"template-provisioning-dialog","aria-labelledby":"template-provisioning-title",onClose:n,onClick:T=>{T.target===T.currentTarget&&h()},onCancel:T=>{r&&T.preventDefault()},children:e("form",{onSubmit:T=>{L(T)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Template provisioning"}),e("h2",{id:"template-provisioning-title",children:["Provisioning \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:h,children:"\xD7"})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Provisioning script"}),e("input",{type:"text",placeholder:"/usr/local/sbin/rookery-provision-template",value:c,onInput:T=>a(T.currentTarget.value)}),e("span",{class:"field-hint",children:["Absolute path to a script run after the backend clones this template. It receives the provisioning context and custom properties in ",e("code",{children:"ROOKERY_PROVISION_JSON"}),". Leave empty to disable provisioning."]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Timeout"}),e("input",{type:"text",placeholder:"5m",value:o,onInput:T=>f(T.currentTarget.value)}),e("span",{class:"field-hint",children:"Examples: 30s, 5m, 1h."})]}),w?e("p",{class:"form-error",role:"alert",children:w}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:r,onClick:h,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:r,children:r?"Saving\u2026":"Save provisioning"})]})]})})}var Os=new Intl.NumberFormat;function Us(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function qs(t){let n=Number(t)||0;return n<=0?"unlimited":n>=1024**3?`${(n/1024**3).toFixed(1)} GiB/s`:n>=1024**2?`${(n/1024**2).toFixed(1)} MiB/s`:n>=1024?`${(n/1024).toFixed(1)} KiB/s`:`${n} B/s`}function ui(t){let n=Number(t.size_bytes)||0;return n<=0?`${Os.format(t.size_gb)} GB`:n>=1024**4?`${(n/1024**4).toFixed(n%1024**4===0?0:1)} TB`:n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:`${Os.format(n)} B`}function Ws(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l(!0),[f,r]=l(""),[g,w]=l(""),[d,k]=l(!1),[h,L]=l(null),[T,m]=l(null),[y,C]=l(null),[E,P]=l(null),A=Me("disk"),b=Me("storage_backend"),_=je();K(()=>{C(null),P(null)},[_.path,_.search]),K(()=>{let[p,N,X]=_.segments;if(p==="disks"){k(N==="new"),L(null),m(null);return}if(p==="storage-backends"){if(k(!1),N==="new"){L({backend:null}),m(null);return}let ae=s.find($=>String($.id)===N);ae?(L(X==="edit"?{backend:ae}:null),m(X==="import"?ae:null)):(L(null),m(null));return}k(!1),L(null),m(null)},[_.path,s]);let R=p=>{n(p),window.dispatchEvent(new CustomEvent("rookery:disks-changed",{detail:p}))},H=async(p=!0)=>{try{let N=await M("/api/disks");R(Array.isArray(N)?N:[]),p&&r("")}catch(N){p&&r(N instanceof Error?N.message:String(N))}},G=async(p=!0)=>{try{let N=await M("/api/storage-backends"),X=Array.isArray(N)?N:[];X.sort((ae,$)=>ae.name.localeCompare($.name,void 0,{sensitivity:"base"})),c(X),p&&w("")}catch(N){p&&w(N instanceof Error?N.message:String(N))}},re=async()=>{o(!0),await Promise.allSettled([H(),G()]),o(!1)};K(()=>{re();let p=ae=>{let $=ae.detail;Array.isArray($)&&n($)},N=()=>{te("/disks/new")},X=ae=>{ae.detail==="disks"&&re()};return window.addEventListener("rookery:disks-data",p),window.addEventListener("rookery:add-disk",N),window.addEventListener("rookery:tab-changed",X),()=>{window.removeEventListener("rookery:disks-data",p),window.removeEventListener("rookery:add-disk",N),window.removeEventListener("rookery:tab-changed",X)}},[]),K(()=>{let p=document.getElementById("disk-tab-count");p&&(p.textContent=String(t.length))},[t.length]);let x=async p=>{if(p.attached_vm_id){le(`Detach ${p.name} from ${p.attached_vm_name||"its VM"} before deleting it.`,!0);return}if(window.confirm(p.source==="host-device"?`Delete disk "${p.name}"? + +This removes only the inventory record. The host block device ${p.device} is not modified.`:p.source==="kouja"?`Delete disk "${p.name}"? + +This queues the kouja volume for unexport and deletion. The disk stays visible until cleanup finishes.`:`Delete disk "${p.name}"? + +This removes the LVM thin volume and its inventory record.`))try{await M(`/api/disks/${p.id}`,{method:"DELETE"}),p.source==="kouja"?(await H(),le(`Deletion queued for ${p.name}`)):(R(t.filter(X=>X.id!==p.id)),le(`Deleted disk ${p.name}`)),r(""),G(!1)}catch(X){r(`Could not delete ${p.name}: ${X instanceof Error?X.message:String(X)}`)}},F=async p=>{if(p.source!=="lvm-thin"&&p.source){le("Only LVM thin disks can be templates.",!0);return}if(p.attached_vm_id){le("Detach the disk from its VM before changing template status.",!0);return}try{let N=await M(`/api/disks/${p.id}`,{method:"PUT",body:JSON.stringify({is_template:!p.is_template})});R(t.map(X=>X.id===N.id?N:X)),le(N.is_template?`Marked ${N.name} as a template`:`Unmarked ${N.name} as a template`)}catch(N){r(`Could not update ${p.name}: ${N instanceof Error?N.message:String(N)}`)}},B=async p=>{if(p.disk_count>0){le("Delete disks using this backend first.",!0);return}if(window.confirm(`Delete storage backend "${p.name}"?`))try{await M(`/api/storage-backends/${p.id}`,{method:"DELETE"}),c(s.filter(X=>X.id!==p.id)),w(""),le(`Deleted storage backend ${p.name}`)}catch(X){w(`Could not delete ${p.name}: ${X instanceof Error?X.message:String(X)}`)}},z=t.filter(p=>p.attached_vm_id).length,J=t.length===0?a?"Loading disks\u2026":"No disks registered":`${t.length} disk${t.length===1?"":"s"} \xB7 ${z} attached`,se=s.reduce((p,N)=>p+(N.disk_count||0),0),ge=s.length===0?a?"Loading storage backends\u2026":"No storage backends registered":`${s.length} backend${s.length===1?"":"s"} \xB7 ${se} disk${se===1?"":"s"}`;return e(ye,{children:[e("section",{class:"panel","aria-labelledby":"storage-backends-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"storage-backends-heading",children:"Storage backends"}),e("p",{class:"panel-subtitle",children:ge})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{re()},children:a?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>te("/storage-backends/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add backend"]})]})]}),g?e("div",{class:"banner",role:"alert",children:g}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Driver"}),e("th",{scope:"col",children:"Location"}),e("th",{scope:"col",children:"Disks"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:s.map(p=>{let N=p.driver==="lvm-thin"&&p.config?.vg&&p.config?.thin_pool;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:p.name}),p.title||p.description?e("span",{class:"vm-description",title:p.description||p.title||"",children:[p.title,p.description].filter(Boolean).join(" \xB7 ")}):null,e(Ve,{entityType:"storage_backend",entityID:p.id,name:p.name,labels:b.labels[String(p.id)],onSaved:X=>b.setEntityLabels(String(p.id),X)})]}),e("td",{class:"mono",children:p.driver==="lvm-thin"?"LVM thin":p.driver==="kouja"?"Kouja":p.driver}),e("td",{class:"mono",children:[p.driver==="kouja"?`${p.config?.kouja?.endpoint||"\u2014"} \xB7 ${p.config?.kouja?.storage_class||"\u2014"}`:`${p.config?.vg||"\u2014"}/${p.config?.thin_pool||"\u2014"}`,p.config?.supports_templates?e("span",{class:"import-disk-badge template-backend-badge",children:"templates"}):null]}),e("td",{class:p.disk_count?"mono":"mono muted",children:String(p.disk_count||0)}),e("td",{class:"updated hide-sm",children:Us(p.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!N,title:N?"Import existing thin volumes from this backend.":"Import is available for configured LVM thin backends.",onClick:()=>te(`/storage-backends/${p.id}/import`),children:"Import"}),e("button",{class:"row-action",type:"button",onClick:()=>te(`/storage-backends/${p.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",disabled:p.disk_count>0,title:p.disk_count>0?"Delete disks using this backend first.":"",onClick:()=>{B(p)},children:"Delete"})]})})]},p.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A4"}),e("h3",{children:"No storage backends yet"}),e("p",{children:"Register an LVM thin pool before creating disks."}),e("button",{class:"button primary",type:"button",onClick:()=>te("/storage-backends/new"),children:"Add a storage backend"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"disks-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"disks-heading",children:"Disks"}),e("p",{class:"panel-subtitle",children:J})]}),e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{re()},children:a?"Refreshing\u2026":"Refresh"})]}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Backend"}),e("th",{scope:"col",children:"Attached to"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(p=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:p.name}),p.is_template?e("span",{class:"import-disk-badge",children:"template"}):null,p.source==="host-device"?e("span",{class:"import-disk-badge",children:"passthrough"}):null,p.source==="kouja"?e("span",{class:"import-disk-badge",children:"kouja"}):null,p.deletion_state?e("span",{class:"import-disk-badge",title:p.deletion_error||p.deletion_state,children:p.deletion_state==="delete_failed"?"delete failed":p.deletion_state}):null,p.template_disk_name?e("span",{class:"vm-description",children:["from ",p.template_disk_name]}):null,p.locator?e("span",{class:"vm-description",title:p.locator,children:p.locator}):p.device?e("span",{class:"vm-description",title:p.device,children:p.device}):null,e(Ve,{entityType:"disk",entityID:p.id,name:p.name,labels:A.labels[String(p.id)],onSaved:N=>A.setEntityLabels(String(p.id),N)})]}),e("td",{class:"mono",children:[ui(p),p.read_bps||p.write_bps?e("span",{class:"vm-description",children:[qs(p.read_bps)," read \xB7 ",qs(p.write_bps)," write"]}):null]}),e("td",{class:p.storage_backend_name||p.source==="host-device"?"mono":"mono muted",children:p.source==="host-device"?"host device":p.storage_backend_name||"\u2014"}),e("td",{class:p.is_template?"mono muted":p.attached_vm_id?"mono":"mono muted",children:p.is_template?"template":p.attached_vm_id?p.attached_vm_name||p.attached_vm_id:"unattached"}),e("td",{class:"updated hide-sm",children:Us(p.updated_at)}),e("td",{children:e("div",{class:"actions",children:[p.is_template?e("button",{class:"row-action",type:"button",onClick:()=>C(p),children:"Provisioning"}):null,e("button",{class:"row-action",type:"button",disabled:!!p.deletion_state,title:p.deletion_state?"This disk is being deleted.":"Set QEMU read/write bandwidth limits; restart the VM to apply them.",onClick:()=>P(p),children:"Limits"}),p.source==="host-device"||p.source&&p.source!=="lvm-thin"?null:e("button",{class:"row-action",type:"button",disabled:!!p.attached_vm_id,title:p.is_template?"Allow this disk to be used as a normal disk again.":"Mark this unattached disk as a template.",onClick:()=>{F(p)},children:p.is_template?"Unmark":"Template"}),e("button",{class:"row-action danger",type:"button",disabled:!!p.attached_vm_id,title:p.attached_vm_id?`Detach ${p.name} from ${p.attached_vm_name||"its VM"} before deleting it.`:"",onClick:()=>{x(p)},children:"Delete"})]})})]},p.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A3"}),e("h3",{children:"No disks yet"}),e("p",{children:["Create an LVM thin disk here, pass through a host block device, or use"," ",e("strong",{children:"Import"})," on a storage backend to register existing thin volumes."]}),e("button",{class:"button primary",type:"button",onClick:()=>te("/disks/new"),children:"Create a disk"})]})})]}),y?e(Hs,{disk:y,onClose:()=>C(null),onSaved:p=>{C(null),R(t.map(N=>N.id===p.id?p:N)),le(`Saved provisioning for ${p.name}`)}}):null,E?e(Rs,{disk:E,onClose:()=>P(null),onSaved:p=>{P(null),R(t.map(N=>N.id===p.id?p:N)),le(`Saved bandwidth limits for ${p.name}`)}}):null,d?e($s,{backends:s,disks:t,onClose:()=>{k(!1),te("/disks",!0)},onSaved:p=>{k(!1),te("/disks",!0),r(""),le(`Created disk ${p.name}`),H(),G(!1)}}):null,h?e(Bs,{backend:h.backend,onClose:()=>{L(null),te("/disks",!0)},onSaved:p=>{L(null),te("/disks",!0),w(""),le(`${h.backend?"Saved":"Added"} storage backend ${p.name}`),G(),H(!1)}},h.backend?`edit-${h.backend.id}`:"new"):null,T?e(Fs,{backend:T,onClose:()=>{m(null),te("/disks",!0)},onImported:p=>{m(null),te("/disks",!0),r(""),le(`Imported ${p.length} disk${p.length===1?"":"s"}`),H(),G(!1)}},`import-${T.id}`):null]})}function Gs(t){t&&Oe(e(Ws,{}),t)}function pi(t){let n=t.response;return n&&typeof n=="object"&&"error"in n&&typeof n.error=="string"?n.error:t.statusText||`Upload failed (${t.status})`}function zs(t,n,s){return new Promise((c,a)=>{let o=new FormData;o.append("kind",t),o.append("file",n,n.name);let f=new XMLHttpRequest;f.open("POST","/api/lightvm/uploads"),f.responseType="json",f.upload.onprogress=r=>{r.lengthComputable&&r.total>0&&s?.(r.loaded/r.total)},f.onerror=()=>a(new Error("Upload failed because the API could not be reached")),f.onabort=()=>a(new Error("Upload was cancelled")),f.onload=()=>{if(f.status>=200&&f.status<300&&f.response){c(f.response);return}a(new Error(pi(f)))},f.send(o)})}async function Ks(t){t&&await M(`/api/lightvm/uploads?path=${encodeURIComponent(t)}`,{method:"DELETE"})}var fi={kernel:{endpoint:"/api/lightvm/kernels",entityType:"lightvm_kernel",uploadKind:"kernel",noun:"kernel",namePlaceholder:"vmlinuz-6.12"},initramfs:{endpoint:"/api/lightvm/initramfs",entityType:"lightvm_initramfs",uploadKind:"initramfs",noun:"initramfs",namePlaceholder:"initramfs-6.12"}},mi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function js(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(1)} MB`:n>=1024?`${(n/1024).toFixed(1)} KB`:`${n} B`}function Js({kind:t,artifact:n,onClose:s,onSaved:c}){let a=fi[t],o=n!==null,[f,r]=l(n?.name||""),[g,w]=l(n?.description||""),[d,k]=l(null),[h,L]=Pe(a.entityType,n?.id),[T,m]=l(!1),[y,C]=l(null),[E,P]=l(""),A=ee(null),b=ee(null),_=ee(null);K(()=>{A.current?.showModal(),b.current?.focus()},[]);let R=()=>{T||A.current?.close()},H=async G=>{if(G.preventDefault(),T||!_.current?.reportValidity())return;if(!o&&!d){P(`Choose an ${a.noun} file to upload.`);return}P(""),m(!0);let re="";try{let x=n?.path||"",F=n?.file_name||"",B=n?.size_bytes||0;if(d){C(0);let se=await zs(a.uploadKind,d,C);re=se.path,x=se.path,F=se.file_name,B=se.size_bytes}let z={name:f.trim(),description:g.trim(),path:x,file_name:F},J=o?await M(`${a.endpoint}/${n.id}`,{method:"PUT",body:JSON.stringify(z)}):await M(a.endpoint,{method:"POST",body:JSON.stringify(z)});re="",await Ee(a.entityType,J.id,J.name,h),c({...J,size_bytes:J.size_bytes||B},!o)}catch(x){re&&await Ks(re).catch(()=>{}),P(x instanceof Error?x.message:String(x))}finally{m(!1),C(null)}};return e("dialog",{ref:A,id:`${a.uploadKind}-dialog`,"aria-labelledby":`${a.uploadKind}-dialog-title`,onClose:s,onClick:G=>{G.target===G.currentTarget&&R()},onCancel:G=>{T&&G.preventDefault()},children:e("form",{ref:_,novalidate:!0,onSubmit:G=>{H(G)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:o?`Edit ${a.noun}`:`New ${a.noun}`}),e("h2",{id:`${a.uploadKind}-dialog-title`,children:o?`Edit LightVM ${a.noun}`:`Add LightVM ${a.noun}`})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:R,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:b,type:"text",required:!0,maxlength:127,pattern:mi,autocomplete:"off",spellcheck:!1,placeholder:a.namePlaceholder,value:f,onInput:G=>r(G.currentTarget.value)}),e("span",{class:"field-hint",children:["A short label for this ",a.noun," image."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Description"}),e("input",{type:"text",maxlength:2e3,autocomplete:"off",placeholder:"Optional notes, version, or source",value:g,onInput:G=>w(G.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:o?`Replace ${a.noun} file`:`${a.noun[0].toUpperCase()}${a.noun.slice(1)} file`}),e("input",{type:"file",required:!o,disabled:T,onChange:G=>k(G.currentTarget.files?.[0]||null)}),e("span",{class:"field-hint",children:o&&n?`Current file: ${n.file_name||n.path} (${js(n.size_bytes)}). Choose a file only to replace it.`:"The file is uploaded to the Rookery host and stored under the configured LightVM directory."}),d?e("span",{class:"field-hint",children:["Selected: ",d.name," (",js(d.size),")"]}):null]}),y!==null?e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Uploading"}),e("progress",{max:100,value:Math.round(y*100)}),e("span",{class:"field-hint",children:[Math.round(y*100),"%"]})]}):null,e("div",{class:"field field-wide",children:e(Le,{labels:h,onChange:L,disabled:T})})]}),E?e("p",{class:"form-error",role:"alert",children:E}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:T,onClick:R,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:T,children:T?y!==null?"Uploading\u2026":"Saving\u2026":o?`Save ${a.noun}`:`Add ${a.noun}`})]})]})})}var hi="[A-Za-z0-9][A-Za-z0-9._\\-]*",bi=/^[A-Za-z_][A-Za-z0-9_]*$/,hn="console=hvc0 rdinit=/init";function gi(t){return Array.isArray(t?.consoles)&&t.consoles.length>0?t.consoles.map(n=>({id:n.id,name:n.name})):[{id:0,name:"console"}]}function vi(t){if(t.length===0)return"console";let n=new Set(t.map(c=>c.name.trim().toLowerCase())),s=t.length+1;for(;n.has(`console-${s}`);)s+=1;return`console-${s}`}function yi(t){return Array.isArray(t?.env)?t.env.map(n=>`${n.name}=${n.value}`).join(` +`):""}function _i(t){let n=[],s=new Set,c=t.split(` +`);for(let a=0;a128)return{env:[],error:`Environment variable ${w} on line ${a+1} is too long.`};if(d.length>4096)return{env:[],error:`Environment value for ${w} on line ${a+1} is too long.`};if(d.includes("\0"))return{env:[],error:`Environment value for ${w} on line ${a+1} contains a NUL character.`};if(s.has(w))return{env:[],error:`Environment variable ${w} is duplicated (line ${a+1}).`};s.add(w),n.push({name:w,value:d})}return{env:n,error:""}}function wi(t){return Array.isArray(t?.interfaces)?t.interfaces:[]}function Xs({vm:t,kernels:n,initramfs:s,interfaces:c,onClose:a,onSaved:o}){let f=t!==null,[r,g]=l(t?.name||""),[w,d]=l(t?.description||""),[k,h]=l(String(t?.vcpus??1)),[L,T]=l(String(t?.memory_mb??512)),[m,y]=l(!!t?.console_enabled),[C,E]=l(null),[P,A]=l(t?.cgroup_class||""),[b,_]=l(t?.kernel_id?String(t.kernel_id):""),[R,H]=l(t?.initramfs_id?String(t.initramfs_id):""),[G,re]=l(t?.image||""),[x,F]=l(()=>yi(t)),[B,z]=l(()=>gi(t)),[J,se]=l(t?.executable_path||""),[ge,p]=l(Array.isArray(t?.executable_args)?t.executable_args.join(` +`):""),[N,X]=l(t?t.append||"":hn),[ae,$]=l(()=>wi(t)),[S,v]=l(""),[V,ie]=l(""),[pe,fe]=Pe("lightvm",t?.id),[ne,me]=l(!1),[ue,u]=l(""),I=ee(null),U=ee(null),Q=ee(null),Z=new Set(ae.map(W=>W.id)),q=Array.isArray(C?.classes)?C.classes:[],_e=q.filter(W=>!q.some(ke=>ke.name.startsWith(W.name+"/"))),oe=c.filter(W=>Z.has(W.id)||W.attached_vm_id?!1:!W.attached_lightvm_id||t!==null&&W.attached_lightvm_id===t.id);K(()=>{I.current?.showModal(),U.current?.focus()},[]),K(()=>{M("/api/cgroup-settings").then(E).catch(()=>E(null))},[]),K(()=>{oe.length===0&&ae.length===0?ie("Create a Rookery Virtual Interface on the Network tab first."):ie("Choose an interface and click Add. Interfaces attached to VMs or other LightVMs are hidden.")},[oe.length,ae.length]);let he=()=>{ne||I.current?.close()},ve=()=>{let W=Number(S);if(!W)return;let ke=c.find(Se=>Se.id===W)||ae.find(Se=>Se.id===W);ke&&($(Se=>Se.some(De=>De.id===ke.id)?Se:[...Se,ke]),v(""))},j=W=>{$(ke=>ke.filter(Se=>Se.id!==W))},$e=(W,ke)=>{z(Se=>Se.map((De,He)=>He===W?{...De,name:ke}:De))},O=W=>{z(ke=>ke.filter((Se,De)=>De!==W))},we=()=>{z(W=>[...W,{id:0,name:vi(W)}])},Te=async W=>{if(W.preventDefault(),ne||!Q.current?.reportValidity())return;let ke=Number(b),Se=Number(R);if(!ke||!Se){u("Select a kernel and an initramfs image.");return}if(B.length===0){u("Add at least one console.");return}let De=[],He=new Set;for(let Ae of B){let Ie=Ae.name.trim();if(!Ie){u("Every console needs a name.");return}let Je=Ie.toLowerCase();if(He.has(Je)){u(`Console ${Ie} is duplicated.`);return}He.add(Je),De.push({id:Ae.id,name:Ie})}let Xe=_i(x);if(Xe.error){u(Xe.error);return}let ce=ge.split(` +`).map(Ae=>Ae.replace(/\r$/,"")).filter(Ae=>Ae.length>0),Re={name:r.trim(),description:w.trim(),vcpus:Number(k),memory_mb:Number(L),console_enabled:m,cgroup_class:P,kernel_id:ke,initramfs_id:Se,image:G.trim(),env:Xe.env,executable_path:J.trim(),executable_args:ce,append:N.trim(),consoles:De,interface_ids:ae.map(Ae=>Ae.id)};u(""),me(!0);try{let Ae=await M(f?`/api/lightvm/instances/${t.id}`:"/api/lightvm/instances",{method:f?"PUT":"POST",body:JSON.stringify(Re)});await Ee("lightvm",Ae.id,Ae.name||r.trim(),pe),o(Ae)}catch(Ae){u(Ae instanceof Error?Ae.message:String(Ae))}finally{me(!1)}};return e("dialog",{ref:I,id:"lightvm-dialog","aria-labelledby":"lightvm-dialog-title",onClose:a,onClick:W=>{W.target===W.currentTarget&&he()},onCancel:W=>{ne&&W.preventDefault()},children:e("form",{ref:Q,novalidate:!0,onSubmit:W=>{Te(W)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:f?"Edit LightVM":"New LightVM"}),e("h2",{id:"lightvm-dialog-title",children:f?`Edit ${t?.name}`:"Create LightVM"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:he,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:U,type:"text",required:!0,maxlength:64,pattern:hi,autocomplete:"off",spellcheck:!1,placeholder:"app-01",value:r,onInput:W=>g(W.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"vCPUs"}),e("input",{type:"number",required:!0,min:1,max:128,step:1,value:k,onInput:W=>h(W.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory (MB)"}),e("input",{type:"number",required:!0,min:128,max:1048576,step:128,value:L,onInput:W=>T(W.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Kernel"}),e("select",{required:!0,value:b,onChange:W=>_(W.currentTarget.value),children:[e("option",{value:"",children:"Select a kernel\u2026"}),n.map(W=>e("option",{value:String(W.id),children:W.name},W.id))]}),e("span",{class:"field-hint",children:n.length===0?"Upload a kernel on the Kernels subtab first.":""})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Initramfs"}),e("select",{required:!0,value:R,onChange:W=>H(W.currentTarget.value),children:[e("option",{value:"",children:"Select an initramfs\u2026"}),s.map(W=>e("option",{value:String(W.id),children:W.name},W.id))]}),e("span",{class:"field-hint",children:s.length===0?"Upload an initramfs on the Initramfs subtab first.":""})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Kernel append"}),e("input",{type:"text",maxlength:4096,autocomplete:"off",spellcheck:!1,placeholder:hn,value:N,onInput:W=>X(W.currentTarget.value)}),e("span",{class:"field-hint",children:["Kernel command line appended at boot. Defaults to ",hn,"."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Container image"}),e("input",{type:"text",required:!0,maxlength:512,autocomplete:"off",spellcheck:!1,placeholder:"ghcr.io/example/app:latest",value:G,onInput:W=>re(W.currentTarget.value)}),e("span",{class:"field-hint",children:"Docker/OCI image reference used by the LightVM runtime."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Executable path"}),e("input",{type:"text",required:!0,maxlength:4096,autocomplete:"off",spellcheck:!1,placeholder:"/usr/local/bin/app",value:J,onInput:W=>se(W.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path to the executable inside the container image."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Arguments"}),e("textarea",{rows:4,spellcheck:!1,placeholder:`--listen +:8080`,value:ge,onInput:W=>p(W.currentTarget.value)}),e("span",{class:"field-hint",children:["One argument per line. Rookery writes the executable path and these arguments to an executable ",e("code",{children:".entrypoint"})," script at the image root."]})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Environment variables"}),e("textarea",{rows:6,spellcheck:!1,autocomplete:"off",placeholder:`APP_ENV=production DATABASE_URL=postgres://db/app -LOG_LEVEL=info`,value:x,onInput:q=>R(q.currentTarget.value)}),e("span",{class:"field-hint",children:["One ",e("code",{children:"NAME=value"})," per line. Rookery writes these to"," ",e("code",{children:".env"})," at the root of the mounted image before starting the LightVM. Blank lines, ",e("code",{children:"export "})," prefixes, and lines beginning with ",e("code",{children:"#"})," are ignored."]})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Consoles"}),e("div",{class:"console-editor",children:[F.map((q,we)=>e("div",{class:"console-row",children:[e("input",{type:"text",required:!0,maxlength:64,autocomplete:"off",spellcheck:!1,placeholder:we===0?"console":`console-${we+1}`,"aria-label":`Console name ${we+1}`,value:q.name,onInput:Te=>$e(we,Te.currentTarget.value)}),e("button",{class:"row-action danger",type:"button",disabled:F.length<=1,title:F.length<=1?"A LightVM must have at least one console.":"",onClick:()=>O(we),children:"Remove"})]},`${q.id}-${we}`)),e("div",{children:e("button",{class:"button ghost",type:"button",disabled:F.length>=32,onClick:_e,children:"Add console"})})]}),e("span",{class:"field-hint",children:["The first console is exposed as ",e("code",{children:"org.example.console"}),"; additional ports use ",e("code",{children:"org.example.console.N"}),". Open the terminal page to choose which console to use."]})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"field-label",children:"Console access"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:m,onChange:q=>g(q.currentTarget.checked)}),e("span",{children:"Allow end users to open this LightVM's consoles"})]}),e("span",{class:"field-hint",children:"Applies to every console port; disabling it closes existing console sessions."})]}),e("div",{class:"field field-wide",children:e("label",{class:"field",children:[e("span",{class:"field-label",children:["Cgroup class ",e("em",{children:"optional"})]}),e("select",{value:S,onChange:q=>N(q.currentTarget.value),children:[e("option",{value:"",children:"Shared cgroup (host defaults)"}),!Ie.some(q=>q.name===S)&&S?e("option",{value:S,children:[S," (not a leaf class)"]}):null,Ie.map(q=>e("option",{value:q.name,children:q.name},q.name))]}),e("span",{class:"field-hint",children:["Select a leaf class defined in Settings.",P&&!P.enabled?" Host-wide cgroup limits are disabled in Settings; the class applies when they are enabled.":""]})]})}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Network interfaces ",e("em",{children:"optional"})]}),e("div",{class:"interface-picker",children:[e("select",{value:$,disabled:le.length===0,onChange:q=>C(q.currentTarget.value),children:[e("option",{value:"",children:le.length>0?"Select an interface\u2026":"No interfaces available"}),le.map(q=>e("option",{value:String(q.id),children:[q.name," \xB7 ",q.network_name]},q.id))]}),e("button",{class:"button ghost",type:"button",disabled:le.length===0,onClick:ve,children:"Add"})]}),e("ul",{class:"interface-list",children:ce.length===0?e("li",{class:"empty",children:"No interfaces selected"}):ce.map(q=>e("li",{children:[e("span",{class:"interface-list-name",children:[q.name,e("span",{class:"interface-list-network",children:q.network_name})]}),e("button",{class:"row-action danger",type:"button",onClick:()=>K(q.id),children:"Remove"})]},q.id))}),e("span",{class:"field-hint",children:U})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:3,maxlength:2e3,placeholder:"What is this LightVM for?",value:w,onInput:q=>u(q.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Se,{labels:ie,onChange:pe,disabled:ee})})]}),me?e("p",{class:"form-error",role:"alert",children:me}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:ee,onClick:be,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:ee,children:ee?f?"Saving\u2026":"Creating\u2026":f?"Save changes":"Create LightVM"})]})]})})}var Js=[{id:"kernels",label:"Kernels"},{id:"initramfs",label:"Initramfs"},{id:"instances",label:"LightVMs"}];function Xs(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function hn(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function st(t){return[...t].sort((n,s)=>n.name.localeCompare(s.name,void 0,{sensitivity:"base"}))}function Zs(t){return t==="kernel"?"kernels":"initramfs"}function yi(t){return t==="kernel"?"/api/lightvm/kernels":"/api/lightvm/initramfs"}function js(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d([]),[f,r]=d([]),[b,w]=d("kernels"),[u,k]=d(!0),[h,A]=d(""),[I,m]=d(""),[g,P]=d(""),[L,S]=d(""),[N,p]=d(null),[T,H]=d(null),[G,J]=d(null),ne=Ne("lightvm_kernel"),x=Ne("lightvm_initramfs"),R=Ne("lightvm"),F=ze(),W=c=>{n(c),window.dispatchEvent(new CustomEvent("rookery:lightvm-kernels-changed",{detail:c}))},Y=c=>{l(c),window.dispatchEvent(new CustomEvent("rookery:lightvm-initramfs-changed",{detail:c}))},y=c=>{o(c),window.dispatchEvent(new CustomEvent("rookery:lightvm-instances-changed",{detail:c}))},ae=async(c=!0)=>{try{let _=await V("/api/lightvm/kernels");W(st(Array.isArray(_)?_:[])),c&&A("")}catch(_){c&&A(_ instanceof Error?_.message:String(_))}},E=async(c=!0)=>{try{let _=await V("/api/lightvm/initramfs");Y(st(Array.isArray(_)?_:[])),c&&m("")}catch(_){c&&m(_ instanceof Error?_.message:String(_))}},M=async(c=!0)=>{try{let _=await V("/api/lightvm/instances");y(st(Array.isArray(_)?_:[])),c&&P("")}catch(_){c&&P(_ instanceof Error?_.message:String(_))}},se=async()=>{try{let c=await V("/api/interfaces");r(Array.isArray(c)?c:[])}catch{r([])}},ce=async()=>{try{let c=await V("/api/status");p(c.lightvm??null)}catch{p(null)}},v=async()=>{k(!0),await Promise.allSettled([ae(),E(),M(),se(),ce()]),k(!1)};z(()=>{v();let c=Z=>{Z.detail==="lightvm"&&v()},_=()=>Q("/lightvm/kernels/new"),B=()=>Q("/lightvm/initramfs/new"),X=()=>Q("/lightvm/instances/new");return window.addEventListener("rookery:tab-changed",c),window.addEventListener("rookery:add-lightvm-kernel",_),window.addEventListener("rookery:add-lightvm-initramfs",B),window.addEventListener("rookery:add-lightvm-instance",X),()=>{window.removeEventListener("rookery:tab-changed",c),window.removeEventListener("rookery:add-lightvm-kernel",_),window.removeEventListener("rookery:add-lightvm-initramfs",B),window.removeEventListener("rookery:add-lightvm-instance",X)}},[]),z(()=>{let[c,_,B,X]=F.segments;if(c!=="lightvm"){H(null),J(null);return}if(_&&Js.some(Z=>Z.id===_)?w(_):w("kernels"),_==="instances"){if(H(null),B==="new"){J({vm:null});return}if(B&&X==="edit"){let Z=a.find(fe=>fe.id===B);if(Z){J({vm:Z});return}}J(null);return}if(J(null),(_==="kernels"||_==="initramfs")&&B==="new"){H({kind:_==="kernels"?"kernel":"initramfs",artifact:null});return}if(B&&X==="edit"&&(_==="kernels"||_==="initramfs")){let fe=(_==="kernels"?t:s).find(Ie=>String(Ie.id)===B);if(fe){H({kind:_==="kernels"?"kernel":"initramfs",artifact:fe});return}}H(null)},[F.path,t,s,a]),z(()=>{let c=document.getElementById("lightvm-tab-count");c&&(c.textContent=String(a.length))},[a.length]);let $=async(c,_)=>{let B=c==="kernel"?"kernel":"initramfs",X=_.managed?"The uploaded file is deleted from the Rookery host.":"The file on the host is left in place.";if(window.confirm(`Delete ${B} "${_.name}"? +LOG_LEVEL=info`,value:x,onInput:W=>F(W.currentTarget.value)}),e("span",{class:"field-hint",children:["One ",e("code",{children:"NAME=value"})," per line. Rookery writes these to"," ",e("code",{children:".env"})," at the root of the mounted image before starting the LightVM. Blank lines, ",e("code",{children:"export "})," prefixes, and lines beginning with ",e("code",{children:"#"})," are ignored."]})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Consoles"}),e("div",{class:"console-editor",children:[B.map((W,ke)=>e("div",{class:"console-row",children:[e("input",{type:"text",required:!0,maxlength:64,autocomplete:"off",spellcheck:!1,placeholder:ke===0?"console":`console-${ke+1}`,"aria-label":`Console name ${ke+1}`,value:W.name,onInput:Se=>$e(ke,Se.currentTarget.value)}),e("button",{class:"row-action danger",type:"button",disabled:B.length<=1,title:B.length<=1?"A LightVM must have at least one console.":"",onClick:()=>O(ke),children:"Remove"})]},`${W.id}-${ke}`)),e("div",{children:e("button",{class:"button ghost",type:"button",disabled:B.length>=32,onClick:we,children:"Add console"})})]}),e("span",{class:"field-hint",children:["The first console is exposed as ",e("code",{children:"org.example.console"}),"; additional ports use ",e("code",{children:"org.example.console.N"}),". Open the terminal page to choose which console to use."]})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"field-label",children:"Console access"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:m,onChange:W=>y(W.currentTarget.checked)}),e("span",{children:"Allow end users to open this LightVM's consoles"})]}),e("span",{class:"field-hint",children:"Applies to every console port; disabling it closes existing console sessions."})]}),e("div",{class:"field field-wide",children:e("label",{class:"field",children:[e("span",{class:"field-label",children:["Cgroup class ",e("em",{children:"optional"})]}),e("select",{value:P,onChange:W=>A(W.currentTarget.value),children:[e("option",{value:"",children:"Shared cgroup (host defaults)"}),!_e.some(W=>W.name===P)&&P?e("option",{value:P,children:[P," (not a leaf class)"]}):null,_e.map(W=>e("option",{value:W.name,children:W.name},W.name))]}),e("span",{class:"field-hint",children:["Select a leaf class defined in Settings.",C&&!C.enabled?" Host-wide cgroup limits are disabled in Settings; the class applies when they are enabled.":""]})]})}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Network interfaces ",e("em",{children:"optional"})]}),e("div",{class:"interface-picker",children:[e("select",{value:S,disabled:oe.length===0,onChange:W=>v(W.currentTarget.value),children:[e("option",{value:"",children:oe.length>0?"Select an interface\u2026":"No interfaces available"}),oe.map(W=>e("option",{value:String(W.id),children:[W.name," \xB7 ",W.network_name]},W.id))]}),e("button",{class:"button ghost",type:"button",disabled:oe.length===0,onClick:ve,children:"Add"})]}),e("ul",{class:"interface-list",children:ae.length===0?e("li",{class:"empty",children:"No interfaces selected"}):ae.map(W=>e("li",{children:[e("span",{class:"interface-list-name",children:[W.name,e("span",{class:"interface-list-network",children:W.network_name})]}),e("button",{class:"row-action danger",type:"button",onClick:()=>j(W.id),children:"Remove"})]},W.id))}),e("span",{class:"field-hint",children:V})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:3,maxlength:2e3,placeholder:"What is this LightVM for?",value:w,onInput:W=>d(W.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Le,{labels:pe,onChange:fe,disabled:ne})})]}),ue?e("p",{class:"form-error",role:"alert",children:ue}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:ne,onClick:he,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:ne,children:ne?f?"Saving\u2026":"Creating\u2026":f?"Save changes":"Create LightVM"})]})]})})}var Zs=[{id:"kernels",label:"Kernels"},{id:"initramfs",label:"Initramfs"},{id:"instances",label:"LightVMs"}];function Ys(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function bn(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function at(t){return[...t].sort((n,s)=>n.name.localeCompare(s.name,void 0,{sensitivity:"base"}))}function Qs(t){return t==="kernel"?"kernels":"initramfs"}function ki(t){return t==="kernel"?"/api/lightvm/kernels":"/api/lightvm/initramfs"}function ea(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l([]),[f,r]=l([]),[g,w]=l("kernels"),[d,k]=l(!0),[h,L]=l(""),[T,m]=l(""),[y,C]=l(""),[E,P]=l(""),[A,b]=l(null),[_,R]=l(null),[H,G]=l(null),re=Me("lightvm_kernel"),x=Me("lightvm_initramfs"),F=Me("lightvm"),B=je(),z=u=>{n(u),window.dispatchEvent(new CustomEvent("rookery:lightvm-kernels-changed",{detail:u}))},J=u=>{c(u),window.dispatchEvent(new CustomEvent("rookery:lightvm-initramfs-changed",{detail:u}))},se=u=>{o(u),window.dispatchEvent(new CustomEvent("rookery:lightvm-instances-changed",{detail:u}))},ge=async(u=!0)=>{try{let I=await M("/api/lightvm/kernels");z(at(Array.isArray(I)?I:[])),u&&L("")}catch(I){u&&L(I instanceof Error?I.message:String(I))}},p=async(u=!0)=>{try{let I=await M("/api/lightvm/initramfs");J(at(Array.isArray(I)?I:[])),u&&m("")}catch(I){u&&m(I instanceof Error?I.message:String(I))}},N=async(u=!0)=>{try{let I=await M("/api/lightvm/instances");se(at(Array.isArray(I)?I:[])),u&&C("")}catch(I){u&&C(I instanceof Error?I.message:String(I))}},X=async()=>{try{let u=await M("/api/interfaces");r(Array.isArray(u)?u:[])}catch{r([])}},ae=async()=>{try{let u=await M("/api/status");b(u.lightvm??null)}catch{b(null)}},$=async()=>{k(!0),await Promise.allSettled([ge(),p(),N(),X(),ae()]),k(!1)};K(()=>{$();let u=Z=>{Z.detail==="lightvm"&&$()},I=()=>te("/lightvm/kernels/new"),U=()=>te("/lightvm/initramfs/new"),Q=()=>te("/lightvm/instances/new");return window.addEventListener("rookery:tab-changed",u),window.addEventListener("rookery:add-lightvm-kernel",I),window.addEventListener("rookery:add-lightvm-initramfs",U),window.addEventListener("rookery:add-lightvm-instance",Q),()=>{window.removeEventListener("rookery:tab-changed",u),window.removeEventListener("rookery:add-lightvm-kernel",I),window.removeEventListener("rookery:add-lightvm-initramfs",U),window.removeEventListener("rookery:add-lightvm-instance",Q)}},[]),K(()=>{let[u,I,U,Q]=B.segments;if(u!=="lightvm"){R(null),G(null);return}if(I&&Zs.some(Z=>Z.id===I)?w(I):w("kernels"),I==="instances"){if(R(null),U==="new"){G({vm:null});return}if(U&&Q==="edit"){let Z=a.find(q=>q.id===U);if(Z){G({vm:Z});return}}G(null);return}if(G(null),(I==="kernels"||I==="initramfs")&&U==="new"){R({kind:I==="kernels"?"kernel":"initramfs",artifact:null});return}if(U&&Q==="edit"&&(I==="kernels"||I==="initramfs")){let q=(I==="kernels"?t:s).find(_e=>String(_e.id)===U);if(q){R({kind:I==="kernels"?"kernel":"initramfs",artifact:q});return}}R(null)},[B.path,t,s,a]),K(()=>{let u=document.getElementById("lightvm-tab-count");u&&(u.textContent=String(a.length))},[a.length]);let S=async(u,I)=>{let U=u==="kernel"?"kernel":"initramfs",Q=I.managed?"The uploaded file is deleted from the Rookery host.":"The file on the host is left in place.";if(window.confirm(`Delete ${U} "${I.name}"? -${X}`))try{await V(`${yi(c)}/${_.id}`,{method:"DELETE"}),c==="kernel"?(W(t.filter(Z=>Z.id!==_.id)),A("")):(Y(s.filter(Z=>Z.id!==_.id)),m("")),oe(`Deleted ${B} ${_.name}`)}catch(Z){let fe=`Could not delete ${_.name}: ${Z instanceof Error?Z.message:String(Z)}`;c==="kernel"?A(fe):m(fe)}},C=async c=>{let _=`start:${c.id}`;S(_),P("");try{let B=await V(`/api/lightvm/instances/${c.id}/start`,{method:"POST"});y(a.map(X=>X.id===B.id?B:X)),oe(`Started LightVM ${B.name}`)}catch(B){P(`Could not start ${c.name}: ${B instanceof Error?B.message:String(B)}`),M(!1)}finally{S("")}},U=async c=>{let _=`stop:${c.id}`;S(_),P("");try{let B=await V(`/api/lightvm/instances/${c.id}/stop`,{method:"POST"});y(a.map(X=>X.id===B.id?B:X)),oe(`Stopped LightVM ${B.name}`)}catch(B){P(`Could not stop ${c.name}: ${B instanceof Error?B.message:String(B)}`),M(!1)}finally{S("")}},de=c=>{if(!c.console_enabled){oe("Console access is disabled. Edit the LightVM to enable it.",!0);return}if(c.status!=="running"){oe("Start the LightVM before opening its console.",!0);return}window.open(`/lightvm-console.html?vm=${encodeURIComponent(c.id)}`,"_blank","noopener")},ie=async c=>{if(window.confirm(`Delete LightVM "${c.name}"? +${Q}`))try{await M(`${ki(u)}/${I.id}`,{method:"DELETE"}),u==="kernel"?(z(t.filter(Z=>Z.id!==I.id)),L("")):(J(s.filter(Z=>Z.id!==I.id)),m("")),le(`Deleted ${U} ${I.name}`)}catch(Z){let q=`Could not delete ${I.name}: ${Z instanceof Error?Z.message:String(Z)}`;u==="kernel"?L(q):m(q)}},v=async u=>{let I=`start:${u.id}`;P(I),C("");try{let U=await M(`/api/lightvm/instances/${u.id}/start`,{method:"POST"});se(a.map(Q=>Q.id===U.id?U:Q)),le(`Started LightVM ${U.name}`)}catch(U){C(`Could not start ${u.name}: ${U instanceof Error?U.message:String(U)}`),N(!1)}finally{P("")}},V=async u=>{let I=`stop:${u.id}`;P(I),C("");try{let U=await M(`/api/lightvm/instances/${u.id}/stop`,{method:"POST"});se(a.map(Q=>Q.id===U.id?U:Q)),le(`Stopped LightVM ${U.name}`)}catch(U){C(`Could not stop ${u.name}: ${U instanceof Error?U.message:String(U)}`),N(!1)}finally{P("")}},ie=u=>{if(!u.console_enabled){le("Console access is disabled. Edit the LightVM to enable it.",!0);return}if(u.status!=="running"){le("Start the LightVM before opening its console.",!0);return}window.open(`/lightvm-console.html?vm=${encodeURIComponent(u.id)}`,"_blank","noopener")},pe=async u=>{if(window.confirm(`Delete LightVM "${u.name}"? -The definition is removed and its network interfaces are released. No workload is running.`))try{await V(`/api/lightvm/instances/${c.id}`,{method:"DELETE"}),y(a.filter(_=>_.id!==c.id)),se(),P(""),oe(`Deleted LightVM ${c.name}`)}catch(_){P(`Could not delete ${c.name}: ${_ instanceof Error?_.message:String(_)}`)}},pe=t.length===0?u?"Loading kernels\u2026":"No LightVM kernels uploaded":`${t.length} kernel${t.length===1?"":"s"}`,ee=s.length===0?u?"Loading initramfs images\u2026":"No LightVM initramfs uploaded":`${s.length} initramfs image${s.length===1?"":"s"}`,ue=a.length===0?u?"Loading LightVMs\u2026":"No LightVMs defined":`${a.length} LightVM${a.length===1?"":"s"} \xB7 ${a.filter(c=>c.status==="running").length} running`,me=N?.ready!==!1;return e(ge,{children:[N&&!N.ready?e("div",{class:"banner",role:"alert",children:[e("strong",{children:"LightVM runtime unavailable."})," ",N.detail||"Install buildah and virtiofsd before starting a LightVM."]}):null,e("div",{class:"subtabs",role:"tablist","aria-label":"LightVM sections",children:Js.map(c=>e("button",{class:"subtab",type:"button",role:"tab","aria-selected":b===c.id,onClick:()=>Q(`/lightvm/${c.id}`),children:c.label},c.id))}),e("section",{class:"panel","aria-labelledby":"kernels-heading",hidden:b!=="kernels",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"kernels-heading",children:"LightVM kernels"}),e("p",{class:"panel-subtitle",children:pe})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:u,onClick:()=>{v()},children:u?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/lightvm/kernels/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add kernel"]})]})]}),e("p",{class:"field-hint",children:"Kernel images are uploaded to the host and paired with an initramfs when a LightVM is created. Names are unique; labels are managed by the shared label editor."}),h?e("div",{class:"banner",role:"alert",children:h}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"File"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(c=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:c.name}),c.description?e("span",{class:"vm-description",title:c.description,children:c.description}):null,e(De,{entityType:"lightvm_kernel",entityID:c.id,name:c.name,labels:ne.labels[String(c.id)],onSaved:_=>ne.setEntityLabels(String(c.id),_)})]}),e("td",{children:[e("span",{class:"vm-name mono",children:c.file_name||"\u2014"}),e("span",{class:"vm-description mono",title:c.path,children:c.path})]}),e("td",{class:"mono",children:Xs(c.size_bytes)}),e("td",{class:"mono",children:c.managed?"uploaded":"host path"}),e("td",{class:"updated hide-sm",children:hn(c.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>Q(`/lightvm/kernels/${c.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{$("kernel",c)},children:"Delete"})]})})]},c.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2301"}),e("h3",{children:"No LightVM kernels yet"}),e("p",{children:"Upload a kernel image to use as the boot kernel for LightVM instances."}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/lightvm/kernels/new"),children:"Add kernel"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"initramfs-heading",hidden:b!=="initramfs",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"initramfs-heading",children:"LightVM initramfs"}),e("p",{class:"panel-subtitle",children:ee})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:u,onClick:()=>{v()},children:u?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/lightvm/initramfs/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add initramfs"]})]})]}),e("p",{class:"field-hint",children:"Initramfs images are uploaded to the host and paired with a kernel when a LightVM is created. Names are unique; labels are managed by the shared label editor."}),I?e("div",{class:"banner",role:"alert",children:I}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"File"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:s.map(c=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:c.name}),c.description?e("span",{class:"vm-description",title:c.description,children:c.description}):null,e(De,{entityType:"lightvm_initramfs",entityID:c.id,name:c.name,labels:x.labels[String(c.id)],onSaved:_=>x.setEntityLabels(String(c.id),_)})]}),e("td",{children:[e("span",{class:"vm-name mono",children:c.file_name||"\u2014"}),e("span",{class:"vm-description mono",title:c.path,children:c.path})]}),e("td",{class:"mono",children:Xs(c.size_bytes)}),e("td",{class:"mono",children:c.managed?"uploaded":"host path"}),e("td",{class:"updated hide-sm",children:hn(c.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>Q(`/lightvm/initramfs/${c.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{$("initramfs",c)},children:"Delete"})]})})]},c.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No LightVM initramfs yet"}),e("p",{children:"Upload an initramfs image to pair with a kernel for LightVM instances."}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/lightvm/initramfs/new"),children:"Add initramfs"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"lightvms-heading",hidden:b!=="instances",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"lightvms-heading",children:"LightVMs"}),e("p",{class:"panel-subtitle",children:ue})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:u,onClick:()=>{v()},children:u?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:t.length===0||s.length===0,title:t.length===0?"Upload a kernel first.":s.length===0?"Upload an initramfs first.":"",onClick:()=>Q("/lightvm/instances/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add LightVM"]})]})]}),e("p",{class:"field-hint",children:"A LightVM combines uploaded kernel and initramfs images with a container image, environment, executable path, and arguments. Definitions are inventory-only until the runtime is added."}),g?e("div",{class:"banner",role:"alert",children:g}):null,e("div",{class:"table-wrap",children:a.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"Resources"}),e("th",{scope:"col",children:"Boot images"}),e("th",{scope:"col",children:"Container image"}),e("th",{scope:"col",children:"Workload"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:a.map(c=>{let _=Array.isArray(c.executable_args)?c.executable_args:[],B=Array.isArray(c.env)?c.env:[],X=Array.isArray(c.interfaces)?c.interfaces:[],Z=Array.isArray(c.consoles)?c.consoles:[],fe=c.status==="running"||c.status==="starting",Ie=L===`start:${c.id}`,le=L===`stop:${c.id}`;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:c.name}),c.description?e("span",{class:"vm-description",title:c.description,children:c.description}):null,e(De,{entityType:"lightvm",entityID:c.id,name:c.name,labels:R.labels[c.id],onSaved:be=>R.setEntityLabels(c.id,be)})]}),e("td",{children:[e("span",{class:"status-badge","data-status":c.status,children:c.status||"unknown"}),c.console_socket?e("span",{class:"vm-description mono",title:c.console_socket,children:c.console_socket}):null]}),e("td",{class:"mono",children:[e("span",{children:[c.vcpus," vCPU \xB7 ",c.memory_mb," MB"]}),c.console_enabled?e("span",{class:"vm-description",children:"console enabled"}):null,e("span",{class:"vm-description",children:[Z.length," console",Z.length===1?"":"s"]}),c.cgroup_class?e("span",{class:"vm-description",children:["cgroup ",c.cgroup_class]}):null]}),e("td",{children:[e("span",{class:"vm-name",children:c.kernel_name||"\u2014"}),e("span",{class:"vm-description",children:c.initramfs_name||"\u2014"}),e("span",{class:"vm-description mono",title:c.append,children:c.append?`append: ${c.append}`:"append: none"})]}),e("td",{class:"mono",children:c.image}),e("td",{children:[e("span",{class:"vm-name mono",title:c.executable_path,children:c.executable_path}),e("span",{class:"vm-description mono",title:_.join(" "),children:_.length?_.join(" "):"no arguments"}),e("span",{class:"vm-description",children:[B.length," env var",B.length===1?"":"s"]})]}),e("td",{class:X.length?"mono":"mono muted",title:X.map(be=>be.name).join(", "),children:X.length?X.map(be=>be.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:hn(c.updated_at)}),e("td",{children:e("div",{class:"actions",children:[fe?e("button",{class:"row-action",type:"button",disabled:le,onClick:()=>{U(c)},children:le?"Stopping\u2026":"Stop"}):e("button",{class:"row-action",type:"button",disabled:Ie||!me,title:me?"":N?.detail||"Install buildah and virtiofsd before starting a LightVM.",onClick:()=>{C(c)},children:Ie?"Starting\u2026":"Start"}),e("button",{class:"row-action",type:"button",disabled:!c.console_enabled||c.status!=="running",title:c.console_enabled?c.status!=="running"?"Start the LightVM before opening its console.":"":"Console access is disabled. Edit the LightVM to enable it.",onClick:()=>de(c),children:"Console"}),e("button",{class:"row-action",type:"button",disabled:fe,title:fe?"Stop the LightVM before editing it.":"",onClick:()=>Q(`/lightvm/instances/${c.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{ie(c)},children:"Delete"})]})})]},c.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A2"}),e("h3",{children:"No LightVMs yet"}),e("p",{children:"Upload a kernel and initramfs, then define a container-backed LightVM."}),e("button",{class:"button primary",type:"button",disabled:t.length===0||s.length===0,title:t.length===0?"Upload a kernel first.":s.length===0?"Upload an initramfs first.":"",onClick:()=>Q("/lightvm/instances/new"),children:"Add LightVM"})]})})]}),T?e(Gs,{kind:T.kind,artifact:T.artifact,onClose:()=>{H(null),Q(`/lightvm/${Zs(T.kind)}`,!0)},onSaved:(c,_)=>{let B=T.kind;if(H(null),Q(`/lightvm/${Zs(B)}`,!0),B==="kernel"){let Z=c;W(st(_?[...t,Z]:t.map(fe=>fe.id===Z.id?Z:fe))),A("")}else{let Z=c;Y(st(_?[...s,Z]:s.map(fe=>fe.id===Z.id?Z:fe))),m("")}let X=B==="kernel"?"kernel":"initramfs";oe(_?`Added ${X} ${c.name}`:`Saved ${X} ${c.name}`)}},`${T.kind}-${T.artifact?T.artifact.id:"new"}`):null,G?e(Ks,{vm:G.vm,kernels:t,initramfs:s,interfaces:f,onClose:()=>{J(null),Q("/lightvm/instances",!0)},onSaved:c=>{let _=G.vm===null;J(null),Q("/lightvm/instances",!0),y(st(_?[...a,c]:a.map(B=>B.id===c.id?c:B))),P(""),se(),oe(_?`Created LightVM ${c.name}`:`Saved LightVM ${c.name}`)}},G.vm?G.vm.id:"new"):null]})}function Ys(t){t&&Fe(e(js,{}),t)}var _i="[A-Za-z0-9][A-Za-z0-9._\\-]*";function Qs({onClose:t,onSaved:n}){let[s,l]=d(""),[a,o]=d(""),[f,r]=ke("media"),[b,w]=d(!1),[u,k]=d(""),h=te(null),A=te(null),I=te(null);z(()=>{h.current?.showModal(),A.current?.focus()},[]);let m=()=>{b||h.current?.close()},g=async P=>{if(P.preventDefault(),!(b||!I.current?.reportValidity())){k(""),w(!0);try{let L=await V("/api/media",{method:"POST",body:JSON.stringify({name:s.trim(),path:a.trim()})});await Ce("media",L.id,L.name,f),n(L)}catch(L){k(L instanceof Error?L.message:String(L))}finally{w(!1)}}};return e("dialog",{ref:h,id:"media-dialog","aria-labelledby":"media-dialog-title",onClose:t,onClick:P=>{P.target===P.currentTarget&&m()},onCancel:P=>{b&&P.preventDefault()},children:e("form",{ref:I,novalidate:!0,onSubmit:P=>{g(P)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"New media"}),e("h2",{id:"media-dialog-title",children:"Add installation media"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:A,type:"text",required:!0,maxlength:127,pattern:_i,autocomplete:"off",spellcheck:!1,placeholder:"debian-13-netinst",value:s,onInput:P=>l(P.currentTarget.value)}),e("span",{class:"field-hint",children:"A short label for the media."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"ISO path"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"/srv/isos/debian-13.1.0-amd64-netinst.iso",value:a,onInput:P=>o(P.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path to an existing read-only file on the host. The file is never modified."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:f,onChange:r,disabled:b})})]}),u?e("p",{class:"form-error",role:"alert",children:u}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:b,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:b,children:b?"Adding\u2026":"Add media"})]})]})})}function wi(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function ki(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function bn(t,n){return n.filter(s=>s.boot_media_id===t)}function ea(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d(!0),[f,r]=d(""),[b,w]=d(!1),u=Ne("media"),k=ze();z(()=>{if(k.segments[0]!=="media"){w(!1);return}w(k.segments[1]==="new")},[k.path]);let h=S=>{n(S),window.dispatchEvent(new CustomEvent("rookery:media-changed",{detail:S}))},A=async(S=!0)=>{try{let N=await V("/api/media");h(Array.isArray(N)?N:[]),S&&r("")}catch(N){S&&r(N instanceof Error?N.message:String(N))}},I=async()=>{try{let S=await V("/api/vms");l(Array.isArray(S)?S:[])}catch{l([])}},m=async()=>{o(!0),await Promise.allSettled([A(),I()]),o(!1)};z(()=>{m();let S=T=>{let H=T.detail;Array.isArray(H)&&n(H)},N=()=>{Q("/media/new")},p=T=>{T.detail==="media"&&m()};return window.addEventListener("rookery:media-data",S),window.addEventListener("rookery:add-media",N),window.addEventListener("rookery:tab-changed",p),()=>{window.removeEventListener("rookery:media-data",S),window.removeEventListener("rookery:add-media",N),window.removeEventListener("rookery:tab-changed",p)}},[]),z(()=>{let S=document.getElementById("media-tab-count");S&&(S.textContent=String(t.length))},[t.length]);let g=async S=>{let N=bn(S.id,s);if(N.length>0){oe(`Clear boot media on ${N.map(T=>T.name).join(", ")} first.`,!0);return}if(window.confirm(`Remove media "${S.name}"? +The definition is removed and its network interfaces are released. No workload is running.`))try{await M(`/api/lightvm/instances/${u.id}`,{method:"DELETE"}),se(a.filter(I=>I.id!==u.id)),X(),C(""),le(`Deleted LightVM ${u.name}`)}catch(I){C(`Could not delete ${u.name}: ${I instanceof Error?I.message:String(I)}`)}},fe=t.length===0?d?"Loading kernels\u2026":"No LightVM kernels uploaded":`${t.length} kernel${t.length===1?"":"s"}`,ne=s.length===0?d?"Loading initramfs images\u2026":"No LightVM initramfs uploaded":`${s.length} initramfs image${s.length===1?"":"s"}`,me=a.length===0?d?"Loading LightVMs\u2026":"No LightVMs defined":`${a.length} LightVM${a.length===1?"":"s"} \xB7 ${a.filter(u=>u.status==="running").length} running`,ue=A?.ready!==!1;return e(ye,{children:[A&&!A.ready?e("div",{class:"banner",role:"alert",children:[e("strong",{children:"LightVM runtime unavailable."})," ",A.detail||"Install buildah and virtiofsd before starting a LightVM."]}):null,e("div",{class:"subtabs",role:"tablist","aria-label":"LightVM sections",children:Zs.map(u=>e("button",{class:"subtab",type:"button",role:"tab","aria-selected":g===u.id,onClick:()=>te(`/lightvm/${u.id}`),children:u.label},u.id))}),e("section",{class:"panel","aria-labelledby":"kernels-heading",hidden:g!=="kernels",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"kernels-heading",children:"LightVM kernels"}),e("p",{class:"panel-subtitle",children:fe})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:d,onClick:()=>{$()},children:d?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>te("/lightvm/kernels/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add kernel"]})]})]}),e("p",{class:"field-hint",children:"Kernel images are uploaded to the host and paired with an initramfs when a LightVM is created. Names are unique; labels are managed by the shared label editor."}),h?e("div",{class:"banner",role:"alert",children:h}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"File"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(u=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:u.name}),u.description?e("span",{class:"vm-description",title:u.description,children:u.description}):null,e(Ve,{entityType:"lightvm_kernel",entityID:u.id,name:u.name,labels:re.labels[String(u.id)],onSaved:I=>re.setEntityLabels(String(u.id),I)})]}),e("td",{children:[e("span",{class:"vm-name mono",children:u.file_name||"\u2014"}),e("span",{class:"vm-description mono",title:u.path,children:u.path})]}),e("td",{class:"mono",children:Ys(u.size_bytes)}),e("td",{class:"mono",children:u.managed?"uploaded":"host path"}),e("td",{class:"updated hide-sm",children:bn(u.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>te(`/lightvm/kernels/${u.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{S("kernel",u)},children:"Delete"})]})})]},u.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2301"}),e("h3",{children:"No LightVM kernels yet"}),e("p",{children:"Upload a kernel image to use as the boot kernel for LightVM instances."}),e("button",{class:"button primary",type:"button",onClick:()=>te("/lightvm/kernels/new"),children:"Add kernel"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"initramfs-heading",hidden:g!=="initramfs",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"initramfs-heading",children:"LightVM initramfs"}),e("p",{class:"panel-subtitle",children:ne})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:d,onClick:()=>{$()},children:d?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>te("/lightvm/initramfs/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add initramfs"]})]})]}),e("p",{class:"field-hint",children:"Initramfs images are uploaded to the host and paired with a kernel when a LightVM is created. Names are unique; labels are managed by the shared label editor."}),T?e("div",{class:"banner",role:"alert",children:T}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"File"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:s.map(u=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:u.name}),u.description?e("span",{class:"vm-description",title:u.description,children:u.description}):null,e(Ve,{entityType:"lightvm_initramfs",entityID:u.id,name:u.name,labels:x.labels[String(u.id)],onSaved:I=>x.setEntityLabels(String(u.id),I)})]}),e("td",{children:[e("span",{class:"vm-name mono",children:u.file_name||"\u2014"}),e("span",{class:"vm-description mono",title:u.path,children:u.path})]}),e("td",{class:"mono",children:Ys(u.size_bytes)}),e("td",{class:"mono",children:u.managed?"uploaded":"host path"}),e("td",{class:"updated hide-sm",children:bn(u.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>te(`/lightvm/initramfs/${u.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{S("initramfs",u)},children:"Delete"})]})})]},u.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No LightVM initramfs yet"}),e("p",{children:"Upload an initramfs image to pair with a kernel for LightVM instances."}),e("button",{class:"button primary",type:"button",onClick:()=>te("/lightvm/initramfs/new"),children:"Add initramfs"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"lightvms-heading",hidden:g!=="instances",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"lightvms-heading",children:"LightVMs"}),e("p",{class:"panel-subtitle",children:me})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:d,onClick:()=>{$()},children:d?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:t.length===0||s.length===0,title:t.length===0?"Upload a kernel first.":s.length===0?"Upload an initramfs first.":"",onClick:()=>te("/lightvm/instances/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add LightVM"]})]})]}),e("p",{class:"field-hint",children:"A LightVM combines uploaded kernel and initramfs images with a container image, environment, executable path, and arguments. Definitions are inventory-only until the runtime is added."}),y?e("div",{class:"banner",role:"alert",children:y}):null,e("div",{class:"table-wrap",children:a.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"Resources"}),e("th",{scope:"col",children:"Boot images"}),e("th",{scope:"col",children:"Container image"}),e("th",{scope:"col",children:"Workload"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:a.map(u=>{let I=Array.isArray(u.executable_args)?u.executable_args:[],U=Array.isArray(u.env)?u.env:[],Q=Array.isArray(u.interfaces)?u.interfaces:[],Z=Array.isArray(u.consoles)?u.consoles:[],q=u.status==="running"||u.status==="starting",_e=E===`start:${u.id}`,oe=E===`stop:${u.id}`;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:u.name}),u.description?e("span",{class:"vm-description",title:u.description,children:u.description}):null,e(Ve,{entityType:"lightvm",entityID:u.id,name:u.name,labels:F.labels[u.id],onSaved:he=>F.setEntityLabels(u.id,he)})]}),e("td",{children:[e("span",{class:"status-badge","data-status":u.status,children:u.status||"unknown"}),u.console_socket?e("span",{class:"vm-description mono",title:u.console_socket,children:u.console_socket}):null]}),e("td",{class:"mono",children:[e("span",{children:[u.vcpus," vCPU \xB7 ",u.memory_mb," MB"]}),u.console_enabled?e("span",{class:"vm-description",children:"console enabled"}):null,e("span",{class:"vm-description",children:[Z.length," console",Z.length===1?"":"s"]}),u.cgroup_class?e("span",{class:"vm-description",children:["cgroup ",u.cgroup_class]}):null]}),e("td",{children:[e("span",{class:"vm-name",children:u.kernel_name||"\u2014"}),e("span",{class:"vm-description",children:u.initramfs_name||"\u2014"}),e("span",{class:"vm-description mono",title:u.append,children:u.append?`append: ${u.append}`:"append: none"})]}),e("td",{class:"mono",children:u.image}),e("td",{children:[e("span",{class:"vm-name mono",title:u.executable_path,children:u.executable_path}),e("span",{class:"vm-description mono",title:I.join(" "),children:I.length?I.join(" "):"no arguments"}),e("span",{class:"vm-description",children:[U.length," env var",U.length===1?"":"s"]})]}),e("td",{class:Q.length?"mono":"mono muted",title:Q.map(he=>he.name).join(", "),children:Q.length?Q.map(he=>he.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:bn(u.updated_at)}),e("td",{children:e("div",{class:"actions",children:[q?e("button",{class:"row-action",type:"button",disabled:oe,onClick:()=>{V(u)},children:oe?"Stopping\u2026":"Stop"}):e("button",{class:"row-action",type:"button",disabled:_e||!ue,title:ue?"":A?.detail||"Install buildah and virtiofsd before starting a LightVM.",onClick:()=>{v(u)},children:_e?"Starting\u2026":"Start"}),e("button",{class:"row-action",type:"button",disabled:!u.console_enabled||u.status!=="running",title:u.console_enabled?u.status!=="running"?"Start the LightVM before opening its console.":"":"Console access is disabled. Edit the LightVM to enable it.",onClick:()=>ie(u),children:"Console"}),e("button",{class:"row-action",type:"button",disabled:q,title:q?"Stop the LightVM before editing it.":"",onClick:()=>te(`/lightvm/instances/${u.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{pe(u)},children:"Delete"})]})})]},u.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A2"}),e("h3",{children:"No LightVMs yet"}),e("p",{children:"Upload a kernel and initramfs, then define a container-backed LightVM."}),e("button",{class:"button primary",type:"button",disabled:t.length===0||s.length===0,title:t.length===0?"Upload a kernel first.":s.length===0?"Upload an initramfs first.":"",onClick:()=>te("/lightvm/instances/new"),children:"Add LightVM"})]})})]}),_?e(Js,{kind:_.kind,artifact:_.artifact,onClose:()=>{R(null),te(`/lightvm/${Qs(_.kind)}`,!0)},onSaved:(u,I)=>{let U=_.kind;if(R(null),te(`/lightvm/${Qs(U)}`,!0),U==="kernel"){let Z=u;z(at(I?[...t,Z]:t.map(q=>q.id===Z.id?Z:q))),L("")}else{let Z=u;J(at(I?[...s,Z]:s.map(q=>q.id===Z.id?Z:q))),m("")}let Q=U==="kernel"?"kernel":"initramfs";le(I?`Added ${Q} ${u.name}`:`Saved ${Q} ${u.name}`)}},`${_.kind}-${_.artifact?_.artifact.id:"new"}`):null,H?e(Xs,{vm:H.vm,kernels:t,initramfs:s,interfaces:f,onClose:()=>{G(null),te("/lightvm/instances",!0)},onSaved:u=>{let I=H.vm===null;G(null),te("/lightvm/instances",!0),se(at(I?[...a,u]:a.map(U=>U.id===u.id?u:U))),C(""),X(),le(I?`Created LightVM ${u.name}`:`Saved LightVM ${u.name}`)}},H.vm?H.vm.id:"new"):null]})}function ta(t){t&&Oe(e(ea,{}),t)}var Ci="[A-Za-z0-9][A-Za-z0-9._\\-]*";function na({onClose:t,onSaved:n}){let[s,c]=l(""),[a,o]=l(""),[f,r]=Pe("media"),[g,w]=l(!1),[d,k]=l(""),h=ee(null),L=ee(null),T=ee(null);K(()=>{h.current?.showModal(),L.current?.focus()},[]);let m=()=>{g||h.current?.close()},y=async C=>{if(C.preventDefault(),!(g||!T.current?.reportValidity())){k(""),w(!0);try{let E=await M("/api/media",{method:"POST",body:JSON.stringify({name:s.trim(),path:a.trim()})});await Ee("media",E.id,E.name,f),n(E)}catch(E){k(E instanceof Error?E.message:String(E))}finally{w(!1)}}};return e("dialog",{ref:h,id:"media-dialog","aria-labelledby":"media-dialog-title",onClose:t,onClick:C=>{C.target===C.currentTarget&&m()},onCancel:C=>{g&&C.preventDefault()},children:e("form",{ref:T,novalidate:!0,onSubmit:C=>{y(C)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"New media"}),e("h2",{id:"media-dialog-title",children:"Add installation media"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:L,type:"text",required:!0,maxlength:127,pattern:Ci,autocomplete:"off",spellcheck:!1,placeholder:"debian-13-netinst",value:s,onInput:C=>c(C.currentTarget.value)}),e("span",{class:"field-hint",children:"A short label for the media."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"ISO path"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"/srv/isos/debian-13.1.0-amd64-netinst.iso",value:a,onInput:C=>o(C.currentTarget.value)}),e("span",{class:"field-hint",children:"Absolute path to an existing read-only file on the host. The file is never modified."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:f,onChange:r,disabled:g})})]}),d?e("p",{class:"form-error",role:"alert",children:d}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:g,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:g,children:g?"Adding\u2026":"Add media"})]})]})})}function Si(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function Ti(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function gn(t,n){return n.filter(s=>s.boot_media_id===t)}function sa(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l(!0),[f,r]=l(""),[g,w]=l(!1),d=Me("media"),k=je();K(()=>{if(k.segments[0]!=="media"){w(!1);return}w(k.segments[1]==="new")},[k.path]);let h=P=>{n(P),window.dispatchEvent(new CustomEvent("rookery:media-changed",{detail:P}))},L=async(P=!0)=>{try{let A=await M("/api/media");h(Array.isArray(A)?A:[]),P&&r("")}catch(A){P&&r(A instanceof Error?A.message:String(A))}},T=async()=>{try{let P=await M("/api/vms");c(Array.isArray(P)?P:[])}catch{c([])}},m=async()=>{o(!0),await Promise.allSettled([L(),T()]),o(!1)};K(()=>{m();let P=_=>{let R=_.detail;Array.isArray(R)&&n(R)},A=()=>{te("/media/new")},b=_=>{_.detail==="media"&&m()};return window.addEventListener("rookery:media-data",P),window.addEventListener("rookery:add-media",A),window.addEventListener("rookery:tab-changed",b),()=>{window.removeEventListener("rookery:media-data",P),window.removeEventListener("rookery:add-media",A),window.removeEventListener("rookery:tab-changed",b)}},[]),K(()=>{let P=document.getElementById("media-tab-count");P&&(P.textContent=String(t.length))},[t.length]);let y=async P=>{let A=gn(P.id,s);if(A.length>0){le(`Clear boot media on ${A.map(_=>_.name).join(", ")} first.`,!0);return}if(window.confirm(`Remove media "${P.name}"? -This removes the inventory entry only. The ISO file is not deleted.`))try{await V(`/api/media/${S.id}`,{method:"DELETE"}),h(t.filter(T=>T.id!==S.id)),r(""),oe(`Removed media ${S.name}`)}catch(T){r(`Could not remove ${S.name}: ${T instanceof Error?T.message:String(T)}`)}},P=t.filter(S=>bn(S.id,s).length>0).length,L=t.length===0?a?"Loading media\u2026":"No installation media registered":`${t.length} media file${t.length===1?"":"s"} \xB7 ${P} in use`;return e(ge,{children:[e("section",{class:"panel","aria-labelledby":"media-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"media-heading",children:"Installation media"}),e("p",{class:"panel-subtitle",children:L})]}),e("div",{class:"actions",children:e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{m()},children:a?"Refreshing\u2026":"Refresh"})})]}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Used as boot media"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(S=>{let N=bn(S.id,s),p=N.length===0?"not used":N.length===1?N[0].name:`${N[0].name} +${N.length-1}`;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:S.name}),S.path?e("span",{class:"vm-description",title:S.path,children:S.path}):null,e(De,{entityType:"media",entityID:S.id,name:S.name,labels:u.labels[String(S.id)],onSaved:T=>u.setEntityLabels(String(S.id),T)})]}),e("td",{class:"mono",children:wi(S.size_bytes)}),e("td",{class:N.length?"mono":"mono muted",title:N.length?N.map(T=>T.name).join(", "):void 0,children:p}),e("td",{class:"updated hide-sm",children:ki(S.updated_at)}),e("td",{children:e("div",{class:"actions",children:e("button",{class:"row-action danger",type:"button",disabled:N.length>0,title:N.length>0?`Clear boot media on ${N.map(T=>T.name).join(", ")} first.`:"",onClick:()=>{g(S)},children:"Remove"})})})]},S.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CE"}),e("h3",{children:"No installation media yet"}),e("p",{children:"Register a read-only ISO file from the host, then choose it as a VM's boot media."}),e("button",{class:"button primary",type:"button",onClick:()=>Q("/media/new"),children:"Add media"})]})})]}),b?e(Qs,{onClose:()=>{w(!1),Q("/media",!0)},onSaved:S=>{w(!1),Q("/media",!0),r(""),oe(`Added media ${S.name}`),A()}}):null]})}function ta(t){t&&Fe(e(ea,{}),t)}function Ye(t){return(t??0).toLocaleString()}function gn(t){let n=String(t.action||"").toUpperCase(),s=String(t.protocol||"any").toLowerCase(),l=`${n} ${s} ${t.source||"0.0.0.0/0"} -> ${t.destination||"0.0.0.0/0"}`;if(s==="tcp"||s==="udp"){let a=Number(t.source_port_start)||0,o=Number(t.source_port_end)||0,f=Number(t.destination_port_start)||0,r=Number(t.destination_port_end)||0;(a||o)&&(l+=` sports ${a||"*"}-${o||a||"*"}`),(f||r)&&(l+=` dports ${f||"*"}-${r||f||"*"}`)}return t.description&&(l+=` \u2014 ${t.description}`),l}function Ft(t,n="ACL"){let s=[`${n}: ${t.name||"(unnamed)"}`,`Title: ${t.title||"\u2014"}`,`Description: ${t.description||"\u2014"}`,"Rules:"],l=Array.isArray(t.rules)?t.rules:[];return l.length===0?s.push(" (no rules)"):l.forEach((a,o)=>{s.push(` ${o+1}. ${gn(a)}`)}),s.push("Default action: DENY (implicit)"),s.join(` -`)}function na(t,n){let s=n.toLowerCase();return t.find(l=>l.name.toLowerCase()===s)}function Bt(t,n){return n.filter(s=>[...s.ingress_acls||(s.ingress_acl_name?[s.ingress_acl_name]:[]),...s.egress_acls||(s.egress_acl_name?[s.egress_acl_name]:[])].some(a=>a.toLowerCase()===t.name.toLowerCase()))}function vn(){return{action:"permit",protocol:"any",source:"0.0.0.0/0",destination:"0.0.0.0/0"}}var Ci="[A-Za-z0-9][A-Za-z0-9._\\-]*";function sa(t){return t.map(n=>{let s=n.protocol==="tcp"||n.protocol==="udp";return{action:n.action,description:n.description?.trim()||"",protocol:n.protocol,source:n.source.trim(),destination:n.destination.trim(),source_port_start:s&&Number(n.source_port_start)||0,source_port_end:s&&Number(n.source_port_end)||0,destination_port_start:s&&Number(n.destination_port_start)||0,destination_port_end:s&&Number(n.destination_port_end)||0}})}function aa({acl:t,vppReady:n,onClose:s,onSaved:l}){let a=t!==null,[o,f]=d(t?.name??""),[r,b]=d(t?.title??""),[w,u]=d(t?.description??""),[k,h]=ke("acl",t?.id),[A,I]=d(()=>t?.rules?.length?t.rules.map(R=>({...R})):[vn()]),[m,g]=d(!1),[P,L]=d(""),S=te(null),N=te(null),p=te(null);z(()=>{S.current?.showModal(),N.current?.focus()},[]);let T=()=>{m||S.current?.close()},H=(x,R,F)=>{I(W=>W.map((Y,y)=>y===x?{...Y,[R]:F}:Y))},G=x=>{I(R=>R.filter((F,W)=>W!==x))},J=async x=>{if(x.preventDefault(),!(m||!n||!p.current?.reportValidity())){if(A.length===0){L("Add at least one rule.");return}L(""),g(!0);try{let R={name:o.trim(),title:r.trim(),description:w.trim(),rules:sa(A)},F=await V(a?`/api/acls/${t?.id}`:"/api/acls",{method:a?"PUT":"POST",body:JSON.stringify(R)});await Ce("acl",F.id,F.name,k),l(F)}catch(R){L(R instanceof Error?R.message:String(R))}finally{g(!1)}}},ne=Ft({name:o.trim(),title:r.trim(),description:w.trim(),rules:sa(A)});return e("dialog",{ref:S,id:"acl-dialog","aria-labelledby":"acl-dialog-title",onClose:s,onClick:x=>{x.target===x.currentTarget&&T()},onCancel:x=>{m&&x.preventDefault()},children:e("form",{ref:p,novalidate:!0,onSubmit:x=>{J(x)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:a?"Edit ACL":"New ACL"}),e("h2",{id:"acl-dialog-title",children:a?`ACL \xB7 ${t?.name}`:"Define ACL"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:T,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:N,type:"text",required:!0,maxlength:64,pattern:Ci,autocomplete:"off",spellcheck:!1,placeholder:"web-ingress",value:o,onInput:x=>f(x.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Title"}),e("input",{type:"text",maxlength:128,autocomplete:"off",placeholder:"Web server ingress",value:r,onInput:x=>b(x.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:2,maxlength:2e3,placeholder:"What this ACL protects",value:w,onInput:x=>u(x.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Se,{labels:k,onChange:h,disabled:m})})]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:"Ordered rules"}),e("span",{class:"field-hint",children:"First match wins. Traffic that matches no rule is denied."})]}),e("button",{class:"button ghost",type:"button",onClick:()=>I(x=>[...x,vn()]),children:"Add rule"})]}),e("div",{class:"acl-rule-rows",children:A.map((x,R)=>{let F=x.protocol==="tcp"||x.protocol==="udp";return e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"acl-rule-label",children:["Rule ",R+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>G(R),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field acl-field-acl-rule-action",children:[e("span",{class:"field-label",children:"Action"}),e("select",{value:x.action,onChange:W=>H(R,"action",W.currentTarget.value),children:[e("option",{value:"permit",children:"Permit"}),e("option",{value:"deny",children:"Deny"})]})]}),e("label",{class:"field acl-field-acl-rule-protocol",children:[e("span",{class:"field-label",children:"Protocol"}),e("select",{value:x.protocol,onChange:W=>H(R,"protocol",W.currentTarget.value),children:[e("option",{value:"any",children:"Any"}),e("option",{value:"tcp",children:"TCP"}),e("option",{value:"udp",children:"UDP"}),e("option",{value:"icmp",children:"ICMP"}),e("option",{value:"icmpv6",children:"ICMPv6"})]})]}),e("label",{class:"field acl-field-acl-rule-source",children:[e("span",{class:"field-label",children:"Source CIDR"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"0.0.0.0/0",value:x.source,onInput:W=>H(R,"source",W.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-destination",children:[e("span",{class:"field-label",children:"Destination CIDR"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"0.0.0.0/0",value:x.destination,onInput:W=>H(R,"destination",W.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-description",children:[e("span",{class:"field-label",children:"Description (optional)"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"Why this rule exists",value:x.description||"",onInput:W=>H(R,"description",W.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-source-start",children:[e("span",{class:"field-label",children:"Source ports"}),e("div",{class:"acl-port-pair",children:[e("label",{class:"acl-port-input",children:[e("span",{children:"Start"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!F,value:x.source_port_start||"",onInput:W=>H(R,"source_port_start",Number(W.currentTarget.value))})]}),e("label",{class:"acl-port-input",children:[e("span",{children:"End"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!F,value:x.source_port_end||"",onInput:W=>H(R,"source_port_end",Number(W.currentTarget.value))})]})]})]}),e("label",{class:"field acl-field-acl-rule-destination-start",children:[e("span",{class:"field-label",children:"Destination ports"}),e("div",{class:"acl-port-pair",children:[e("label",{class:"acl-port-input",children:[e("span",{children:"Start"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!F,value:x.destination_port_start||"",onInput:W=>H(R,"destination_port_start",Number(W.currentTarget.value))})]}),e("label",{class:"acl-port-input",children:[e("span",{children:"End"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!F,value:x.destination_port_end||"",onInput:W=>H(R,"destination_port_end",Number(W.currentTarget.value))})]})]})]})]})]},R)})})]})}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Full ACL preview"}),e("pre",{class:"acl-preview",children:ne})]}),P?e("p",{class:"form-error",role:"alert",children:P}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:m,onClick:T,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:m||!n,title:n?"":"VPP must be ready to save ACLs.",children:m?"Saving\u2026":"Save ACL"})]})]})})}function oa({iface:t,vms:n,onClose:s,onSaved:l}){let[a,o]=d(t.attached_vm_id||""),[f,r]=ke("interface",t.id),[b,w]=d(!1),[u,k]=d(""),h=te(null);z(()=>{h.current?.showModal()},[]);let A=()=>{b||h.current?.close()},I=async m=>{if(m.preventDefault(),!b){k(""),w(!0);try{let g=await V(`/api/interfaces/${t.id}`,{method:"PUT",body:JSON.stringify({attached_vm_id:a||null})});await Ce("interface",g.id,g.name,f),l(g)}catch(g){k(g instanceof Error?g.message:String(g))}finally{w(!1)}}};return e("dialog",{ref:h,id:"attach-dialog","aria-labelledby":"attach-dialog-title",onClose:s,onClick:m=>{m.target===m.currentTarget&&A()},onCancel:m=>{b&&m.preventDefault()},children:e("form",{novalidate:!0,onSubmit:m=>{I(m)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VM attachment"}),e("h2",{id:"attach-dialog-title",children:"Attach interface"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:A,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Interface"}),e("input",{type:"text",readonly:!0,value:t.name})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Attach to VM"}),e("select",{value:a,onChange:m=>o(m.currentTarget.value),children:[e("option",{value:"",children:"Leave unattached"}),n.map(m=>{let g=m.status==="running"||m.status==="starting";return e("option",{value:m.id,children:g?`${m.name} (running \u2014 stop first)`:m.name},m.id)})]}),e("span",{class:"field-hint",children:"Interfaces take effect when the VM is started."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:f,onChange:r,disabled:b})})]}),u?e("p",{class:"form-error",role:"alert",children:u}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:b,onClick:A,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:b,children:b?"Saving\u2026":"Save attachment"})]})]})})}var Si="[A-Za-z0-9][A-Za-z0-9._\\-]*";function ia({networks:t,onClose:n,onSaved:s}){let[l,a]=d(t[0]?String(t[0].id):""),[o,f]=d(""),[r,b]=d(""),[w,u]=ke("host_tap"),[k,h]=d(!1),[A,I]=d(""),m=te(null),g=te(null),P=te(null);z(()=>{m.current?.showModal(),g.current?.focus()},[]);let L=()=>{k||m.current?.close()},S=t.find(p=>String(p.id)===l),N=async p=>{if(p.preventDefault(),!(k||!P.current?.reportValidity())){I(""),h(!0);try{let T=await V("/api/host/taps",{method:"POST",body:JSON.stringify({network_id:Number(l),name:o.trim(),ipv4_address:r.trim()})});await Ce("host_tap",T.id,T.name,w),s(T)}catch(T){I(T instanceof Error?T.message:String(T))}finally{h(!1)}}};return e("dialog",{ref:m,id:"host-tap-dialog","aria-labelledby":"host-tap-dialog-title",onClose:n,onClick:p=>{p.target===p.currentTarget&&L()},onCancel:p=>{k&&p.preventDefault()},children:e("form",{ref:P,novalidate:!0,onSubmit:p=>{N(p)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Network to host bridge"}),e("h2",{id:"host-tap-dialog-title",children:"Add host tap"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:L,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Rookery Network"}),e("select",{ref:g,required:!0,value:l,onChange:p=>a(p.currentTarget.value),children:t.map(p=>e("option",{value:p.id,children:[p.name,p.ipv4_cidr?` \xB7 ${p.ipv4_cidr}`:""]},p.id))}),e("span",{class:"field-hint",children:"The tap is bridged into this network, so the host sees its machines at layer 2."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Linux interface name"}),e("input",{type:"text",maxlength:15,pattern:Si,autocomplete:"off",spellcheck:!1,placeholder:S?`hostnet${S.id}`:"hostnet1",value:o,onInput:p=>f(p.currentTarget.value)}),e("span",{class:"field-hint",children:"Optional; defaults to hostnet. Maximum 15 characters."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Host IPv4 address"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"10.0.0.50/16",value:r,onInput:p=>b(p.currentTarget.value)}),e("span",{class:"field-hint",children:"Optional CIDR inside the network, outside the first five reserved addresses. Leave empty to use DHCP or configure the interface manually."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:w,onChange:u,disabled:k})})]}),A?e("p",{class:"form-error",role:"alert",children:A}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:L,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:k||!l,children:k?"Creating\u2026":"Create host tap"})]})]})})}function ra({label:t,hint:n,acls:s,selected:l,onChange:a}){let[o,f]=d(""),r=s.filter(u=>!l.includes(u.name)),b=()=>{o&&(a([...l,o]),f(""))},w=(u,k)=>{let h=u+k;if(h<0||h>=l.length)return;let A=l.slice();[A[u],A[h]]=[A[h],A[u]],a(A)};return e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:t}),e("div",{class:"acl-list-editor",children:[e("div",{class:"acl-list-picker",children:[e("select",{value:o,"aria-label":`Select ${t} ACL`,onChange:u=>f(u.currentTarget.value),children:[e("option",{value:"",children:"Select ACL\u2026"}),r.map(u=>e("option",{value:u.name,children:u.title?`${u.name} \u2014 ${u.title}`:u.name},u.id))]}),e("button",{class:"button ghost",type:"button",disabled:!o,onClick:b,children:"Add"})]}),e("div",{class:"acl-list-selected","aria-label":`Selected ${t} ACL order`,children:l.length===0?e("span",{class:"muted",children:"None"}):l.map((u,k)=>e("div",{class:"acl-order-row",children:[e("span",{class:"mono",children:[k+1,". ",u]}),e("button",{class:"button ghost",type:"button","aria-label":`Move ${u} up`,disabled:k===0,onClick:()=>w(k,-1),children:"\u2191"}),e("button",{class:"button ghost",type:"button","aria-label":`Move ${u} down`,disabled:k===l.length-1,onClick:()=>w(k,1),children:"\u2193"}),e("button",{class:"button ghost danger",type:"button","aria-label":`Remove ${u}`,onClick:()=>a(l.filter(h=>h!==u)),children:"\xD7"})]},u))})]}),e("span",{class:"field-hint",children:n})]})}function la({iface:t,acls:n,vppReady:s,onClose:l,onSaved:a}){let o=t.ingress_acls?.length?t.ingress_acls:t.ingress_acl_name?[t.ingress_acl_name]:[],f=t.egress_acls?.length?t.egress_acls:t.egress_acl_name?[t.egress_acl_name]:[],[r,b]=d(o),[w,u]=d(f),[k,h]=d(!1),[A,I]=d(""),m=te(null);z(()=>{m.current?.showModal()},[]);let g=()=>{k||m.current?.close()},P=async p=>{if(p.preventDefault(),!(k||!s)){I(""),h(!0);try{let T=await V(`/api/interfaces/${t.id}/acls`,{method:"PUT",body:JSON.stringify({ingress_acls:r,egress_acls:w})});a(T)}catch(T){I(T instanceof Error?T.message:String(T))}finally{h(!1)}}},L=[],S=(p,T)=>{if(T.length===0){L.push(`${p}: none`);return}T.forEach((H,G)=>{let J=na(n,H);L.push(J?Ft(J,`${p} #${G+1}`):`${p} #${G+1}: ${H} (missing)`)})};S("From interface ACL",r),S("To interface ACL",w);let N=L.join(` +This removes the inventory entry only. The ISO file is not deleted.`))try{await M(`/api/media/${P.id}`,{method:"DELETE"}),h(t.filter(_=>_.id!==P.id)),r(""),le(`Removed media ${P.name}`)}catch(_){r(`Could not remove ${P.name}: ${_ instanceof Error?_.message:String(_)}`)}},C=t.filter(P=>gn(P.id,s).length>0).length,E=t.length===0?a?"Loading media\u2026":"No installation media registered":`${t.length} media file${t.length===1?"":"s"} \xB7 ${C} in use`;return e(ye,{children:[e("section",{class:"panel","aria-labelledby":"media-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"media-heading",children:"Installation media"}),e("p",{class:"panel-subtitle",children:E})]}),e("div",{class:"actions",children:e("button",{class:"button ghost",type:"button",disabled:a,onClick:()=>{m()},children:a?"Refreshing\u2026":"Refresh"})})]}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Size"}),e("th",{scope:"col",children:"Used as boot media"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(P=>{let A=gn(P.id,s),b=A.length===0?"not used":A.length===1?A[0].name:`${A[0].name} +${A.length-1}`;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:P.name}),P.path?e("span",{class:"vm-description",title:P.path,children:P.path}):null,e(Ve,{entityType:"media",entityID:P.id,name:P.name,labels:d.labels[String(P.id)],onSaved:_=>d.setEntityLabels(String(P.id),_)})]}),e("td",{class:"mono",children:Si(P.size_bytes)}),e("td",{class:A.length?"mono":"mono muted",title:A.length?A.map(_=>_.name).join(", "):void 0,children:b}),e("td",{class:"updated hide-sm",children:Ti(P.updated_at)}),e("td",{children:e("div",{class:"actions",children:e("button",{class:"row-action danger",type:"button",disabled:A.length>0,title:A.length>0?`Clear boot media on ${A.map(_=>_.name).join(", ")} first.`:"",onClick:()=>{y(P)},children:"Remove"})})})]},P.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CE"}),e("h3",{children:"No installation media yet"}),e("p",{children:"Register a read-only ISO file from the host, then choose it as a VM's boot media."}),e("button",{class:"button primary",type:"button",onClick:()=>te("/media/new"),children:"Add media"})]})})]}),g?e(na,{onClose:()=>{w(!1),te("/media",!0)},onSaved:P=>{w(!1),te("/media",!0),r(""),le(`Added media ${P.name}`),L()}}):null]})}function aa(t){t&&Oe(e(sa,{}),t)}function et(t){return(t??0).toLocaleString()}function vn(t){let n=String(t.action||"").toUpperCase(),s=String(t.protocol||"any").toLowerCase(),c=`${n} ${s} ${t.source||"0.0.0.0/0"} -> ${t.destination||"0.0.0.0/0"}`;if(s==="tcp"||s==="udp"){let a=Number(t.source_port_start)||0,o=Number(t.source_port_end)||0,f=Number(t.destination_port_start)||0,r=Number(t.destination_port_end)||0;(a||o)&&(c+=` sports ${a||"*"}-${o||a||"*"}`),(f||r)&&(c+=` dports ${f||"*"}-${r||f||"*"}`)}return t.description&&(c+=` \u2014 ${t.description}`),c}function Ft(t,n="ACL"){let s=[`${n}: ${t.name||"(unnamed)"}`,`Title: ${t.title||"\u2014"}`,`Description: ${t.description||"\u2014"}`,"Rules:"],c=Array.isArray(t.rules)?t.rules:[];return c.length===0?s.push(" (no rules)"):c.forEach((a,o)=>{s.push(` ${o+1}. ${vn(a)}`)}),s.push("Default action: DENY (implicit)"),s.join(` +`)}function oa(t,n){let s=n.toLowerCase();return t.find(c=>c.name.toLowerCase()===s)}function Bt(t,n){return n.filter(s=>[...s.ingress_acls||(s.ingress_acl_name?[s.ingress_acl_name]:[]),...s.egress_acls||(s.egress_acl_name?[s.egress_acl_name]:[])].some(a=>a.toLowerCase()===t.name.toLowerCase()))}function yn(){return{action:"permit",protocol:"any",source:"0.0.0.0/0",destination:"0.0.0.0/0"}}var Pi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function ia(t){return t.map(n=>{let s=n.protocol==="tcp"||n.protocol==="udp";return{action:n.action,description:n.description?.trim()||"",protocol:n.protocol,source:n.source.trim(),destination:n.destination.trim(),source_port_start:s&&Number(n.source_port_start)||0,source_port_end:s&&Number(n.source_port_end)||0,destination_port_start:s&&Number(n.destination_port_start)||0,destination_port_end:s&&Number(n.destination_port_end)||0}})}function ra({acl:t,vppReady:n,onClose:s,onSaved:c}){let a=t!==null,[o,f]=l(t?.name??""),[r,g]=l(t?.title??""),[w,d]=l(t?.description??""),[k,h]=Pe("acl",t?.id),[L,T]=l(()=>t?.rules?.length?t.rules.map(F=>({...F})):[yn()]),[m,y]=l(!1),[C,E]=l(""),P=ee(null),A=ee(null),b=ee(null);K(()=>{P.current?.showModal(),A.current?.focus()},[]);let _=()=>{m||P.current?.close()},R=(x,F,B)=>{T(z=>z.map((J,se)=>se===x?{...J,[F]:B}:J))},H=x=>{T(F=>F.filter((B,z)=>z!==x))},G=async x=>{if(x.preventDefault(),!(m||!n||!b.current?.reportValidity())){if(L.length===0){E("Add at least one rule.");return}E(""),y(!0);try{let F={name:o.trim(),title:r.trim(),description:w.trim(),rules:ia(L)},B=await M(a?`/api/acls/${t?.id}`:"/api/acls",{method:a?"PUT":"POST",body:JSON.stringify(F)});await Ee("acl",B.id,B.name,k),c(B)}catch(F){E(F instanceof Error?F.message:String(F))}finally{y(!1)}}},re=Ft({name:o.trim(),title:r.trim(),description:w.trim(),rules:ia(L)});return e("dialog",{ref:P,id:"acl-dialog","aria-labelledby":"acl-dialog-title",onClose:s,onClick:x=>{x.target===x.currentTarget&&_()},onCancel:x=>{m&&x.preventDefault()},children:e("form",{ref:b,novalidate:!0,onSubmit:x=>{G(x)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:a?"Edit ACL":"New ACL"}),e("h2",{id:"acl-dialog-title",children:a?`ACL \xB7 ${t?.name}`:"Define ACL"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:_,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:A,type:"text",required:!0,maxlength:64,pattern:Pi,autocomplete:"off",spellcheck:!1,placeholder:"web-ingress",value:o,onInput:x=>f(x.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Title"}),e("input",{type:"text",maxlength:128,autocomplete:"off",placeholder:"Web server ingress",value:r,onInput:x=>g(x.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:2,maxlength:2e3,placeholder:"What this ACL protects",value:w,onInput:x=>d(x.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Le,{labels:k,onChange:h,disabled:m})})]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:"Ordered rules"}),e("span",{class:"field-hint",children:"First match wins. Traffic that matches no rule is denied."})]}),e("button",{class:"button ghost",type:"button",onClick:()=>T(x=>[...x,yn()]),children:"Add rule"})]}),e("div",{class:"acl-rule-rows",children:L.map((x,F)=>{let B=x.protocol==="tcp"||x.protocol==="udp";return e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"acl-rule-label",children:["Rule ",F+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>H(F),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field acl-field-acl-rule-action",children:[e("span",{class:"field-label",children:"Action"}),e("select",{value:x.action,onChange:z=>R(F,"action",z.currentTarget.value),children:[e("option",{value:"permit",children:"Permit"}),e("option",{value:"deny",children:"Deny"})]})]}),e("label",{class:"field acl-field-acl-rule-protocol",children:[e("span",{class:"field-label",children:"Protocol"}),e("select",{value:x.protocol,onChange:z=>R(F,"protocol",z.currentTarget.value),children:[e("option",{value:"any",children:"Any"}),e("option",{value:"tcp",children:"TCP"}),e("option",{value:"udp",children:"UDP"}),e("option",{value:"icmp",children:"ICMP"}),e("option",{value:"icmpv6",children:"ICMPv6"})]})]}),e("label",{class:"field acl-field-acl-rule-source",children:[e("span",{class:"field-label",children:"Source CIDR"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"0.0.0.0/0",value:x.source,onInput:z=>R(F,"source",z.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-destination",children:[e("span",{class:"field-label",children:"Destination CIDR"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"0.0.0.0/0",value:x.destination,onInput:z=>R(F,"destination",z.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-description",children:[e("span",{class:"field-label",children:"Description (optional)"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"Why this rule exists",value:x.description||"",onInput:z=>R(F,"description",z.currentTarget.value)})]}),e("label",{class:"field acl-field-acl-rule-source-start",children:[e("span",{class:"field-label",children:"Source ports"}),e("div",{class:"acl-port-pair",children:[e("label",{class:"acl-port-input",children:[e("span",{children:"Start"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!B,value:x.source_port_start||"",onInput:z=>R(F,"source_port_start",Number(z.currentTarget.value))})]}),e("label",{class:"acl-port-input",children:[e("span",{children:"End"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!B,value:x.source_port_end||"",onInput:z=>R(F,"source_port_end",Number(z.currentTarget.value))})]})]})]}),e("label",{class:"field acl-field-acl-rule-destination-start",children:[e("span",{class:"field-label",children:"Destination ports"}),e("div",{class:"acl-port-pair",children:[e("label",{class:"acl-port-input",children:[e("span",{children:"Start"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!B,value:x.destination_port_start||"",onInput:z=>R(F,"destination_port_start",Number(z.currentTarget.value))})]}),e("label",{class:"acl-port-input",children:[e("span",{children:"End"}),e("input",{type:"number",min:1,max:65535,placeholder:"any",disabled:!B,value:x.destination_port_end||"",onInput:z=>R(F,"destination_port_end",Number(z.currentTarget.value))})]})]})]})]})]},F)})})]})}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Full ACL preview"}),e("pre",{class:"acl-preview",children:re})]}),C?e("p",{class:"form-error",role:"alert",children:C}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:m,onClick:_,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:m||!n,title:n?"":"VPP must be ready to save ACLs.",children:m?"Saving\u2026":"Save ACL"})]})]})})}function la({iface:t,vms:n,onClose:s,onSaved:c}){let[a,o]=l(t.attached_vm_id||""),[f,r]=Pe("interface",t.id),[g,w]=l(!1),[d,k]=l(""),h=ee(null);K(()=>{h.current?.showModal()},[]);let L=()=>{g||h.current?.close()},T=async m=>{if(m.preventDefault(),!g){k(""),w(!0);try{let y=await M(`/api/interfaces/${t.id}`,{method:"PUT",body:JSON.stringify({attached_vm_id:a||null})});await Ee("interface",y.id,y.name,f),c(y)}catch(y){k(y instanceof Error?y.message:String(y))}finally{w(!1)}}};return e("dialog",{ref:h,id:"attach-dialog","aria-labelledby":"attach-dialog-title",onClose:s,onClick:m=>{m.target===m.currentTarget&&L()},onCancel:m=>{g&&m.preventDefault()},children:e("form",{novalidate:!0,onSubmit:m=>{T(m)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VM attachment"}),e("h2",{id:"attach-dialog-title",children:"Attach interface"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:L,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Interface"}),e("input",{type:"text",readonly:!0,value:t.name})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Attach to VM"}),e("select",{value:a,onChange:m=>o(m.currentTarget.value),children:[e("option",{value:"",children:"Leave unattached"}),n.map(m=>{let y=m.status==="running"||m.status==="starting";return e("option",{value:m.id,children:y?`${m.name} (running \u2014 stop first)`:m.name},m.id)})]}),e("span",{class:"field-hint",children:"Interfaces take effect when the VM is started."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:f,onChange:r,disabled:g})})]}),d?e("p",{class:"form-error",role:"alert",children:d}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:g,onClick:L,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:g,children:g?"Saving\u2026":"Save attachment"})]})]})})}var Ei="[A-Za-z0-9][A-Za-z0-9._\\-]*";function ca({networks:t,onClose:n,onSaved:s}){let[c,a]=l(t[0]?String(t[0].id):""),[o,f]=l(""),[r,g]=l(""),[w,d]=Pe("host_tap"),[k,h]=l(!1),[L,T]=l(""),m=ee(null),y=ee(null),C=ee(null);K(()=>{m.current?.showModal(),y.current?.focus()},[]);let E=()=>{k||m.current?.close()},P=t.find(b=>String(b.id)===c),A=async b=>{if(b.preventDefault(),!(k||!C.current?.reportValidity())){T(""),h(!0);try{let _=await M("/api/host/taps",{method:"POST",body:JSON.stringify({network_id:Number(c),name:o.trim(),ipv4_address:r.trim()})});await Ee("host_tap",_.id,_.name,w),s(_)}catch(_){T(_ instanceof Error?_.message:String(_))}finally{h(!1)}}};return e("dialog",{ref:m,id:"host-tap-dialog","aria-labelledby":"host-tap-dialog-title",onClose:n,onClick:b=>{b.target===b.currentTarget&&E()},onCancel:b=>{k&&b.preventDefault()},children:e("form",{ref:C,novalidate:!0,onSubmit:b=>{A(b)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Network to host bridge"}),e("h2",{id:"host-tap-dialog-title",children:"Add host tap"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:E,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Rookery Network"}),e("select",{ref:y,required:!0,value:c,onChange:b=>a(b.currentTarget.value),children:t.map(b=>e("option",{value:b.id,children:[b.name,b.ipv4_cidr?` \xB7 ${b.ipv4_cidr}`:""]},b.id))}),e("span",{class:"field-hint",children:"The tap is bridged into this network, so the host sees its machines at layer 2."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Linux interface name"}),e("input",{type:"text",maxlength:15,pattern:Ei,autocomplete:"off",spellcheck:!1,placeholder:P?`hostnet${P.id}`:"hostnet1",value:o,onInput:b=>f(b.currentTarget.value)}),e("span",{class:"field-hint",children:"Optional; defaults to hostnet. Maximum 15 characters."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Host IPv4 address"}),e("input",{type:"text",autocomplete:"off",spellcheck:!1,placeholder:"10.0.0.50/16",value:r,onInput:b=>g(b.currentTarget.value)}),e("span",{class:"field-hint",children:"Optional CIDR inside the network, outside the first five reserved addresses. Leave empty to use DHCP or configure the interface manually."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:w,onChange:d,disabled:k})})]}),L?e("p",{class:"form-error",role:"alert",children:L}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:E,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:k||!c,children:k?"Creating\u2026":"Create host tap"})]})]})})}function da({label:t,hint:n,acls:s,selected:c,onChange:a}){let[o,f]=l(""),r=s.filter(d=>!c.includes(d.name)),g=()=>{o&&(a([...c,o]),f(""))},w=(d,k)=>{let h=d+k;if(h<0||h>=c.length)return;let L=c.slice();[L[d],L[h]]=[L[h],L[d]],a(L)};return e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:t}),e("div",{class:"acl-list-editor",children:[e("div",{class:"acl-list-picker",children:[e("select",{value:o,"aria-label":`Select ${t} ACL`,onChange:d=>f(d.currentTarget.value),children:[e("option",{value:"",children:"Select ACL\u2026"}),r.map(d=>e("option",{value:d.name,children:d.title?`${d.name} \u2014 ${d.title}`:d.name},d.id))]}),e("button",{class:"button ghost",type:"button",disabled:!o,onClick:g,children:"Add"})]}),e("div",{class:"acl-list-selected","aria-label":`Selected ${t} ACL order`,children:c.length===0?e("span",{class:"muted",children:"None"}):c.map((d,k)=>e("div",{class:"acl-order-row",children:[e("span",{class:"mono",children:[k+1,". ",d]}),e("button",{class:"button ghost",type:"button","aria-label":`Move ${d} up`,disabled:k===0,onClick:()=>w(k,-1),children:"\u2191"}),e("button",{class:"button ghost",type:"button","aria-label":`Move ${d} down`,disabled:k===c.length-1,onClick:()=>w(k,1),children:"\u2193"}),e("button",{class:"button ghost danger",type:"button","aria-label":`Remove ${d}`,onClick:()=>a(c.filter(h=>h!==d)),children:"\xD7"})]},d))})]}),e("span",{class:"field-hint",children:n})]})}function ua({iface:t,acls:n,vppReady:s,onClose:c,onSaved:a}){let o=t.ingress_acls?.length?t.ingress_acls:t.ingress_acl_name?[t.ingress_acl_name]:[],f=t.egress_acls?.length?t.egress_acls:t.egress_acl_name?[t.egress_acl_name]:[],[r,g]=l(o),[w,d]=l(f),[k,h]=l(!1),[L,T]=l(""),m=ee(null);K(()=>{m.current?.showModal()},[]);let y=()=>{k||m.current?.close()},C=async b=>{if(b.preventDefault(),!(k||!s)){T(""),h(!0);try{let _=await M(`/api/interfaces/${t.id}/acls`,{method:"PUT",body:JSON.stringify({ingress_acls:r,egress_acls:w})});a(_)}catch(_){T(_ instanceof Error?_.message:String(_))}finally{h(!1)}}},E=[],P=(b,_)=>{if(_.length===0){E.push(`${b}: none`);return}_.forEach((R,H)=>{let G=oa(n,R);E.push(G?Ft(G,`${b} #${H+1}`):`${b} #${H+1}: ${R} (missing)`)})};P("From interface ACL",r),P("To interface ACL",w);let A=E.join(` -`);return e("dialog",{ref:m,id:"interface-acl-dialog","aria-labelledby":"interface-acl-title",onClose:l,onClick:p=>{p.target===p.currentTarget&&g()},onCancel:p=>{k&&p.preventDefault()},children:e("form",{novalidate:!0,onSubmit:p=>{P(p)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface policy"}),e("h2",{id:"interface-acl-title",children:"Assign ACLs"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:g,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.name," \xB7 ",t.network_name," \xB7 ",t.mac]}),e("div",{class:"form-grid",children:[e(ra,{label:"From interface",hint:"Applied to packets entering the interface, in this order. VPP evaluates the first matching rule across the list.",acls:n,selected:r,onChange:b}),e(ra,{label:"To interface",hint:"Applied to packets leaving the interface, in this order.",acls:n,selected:w,onChange:u}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Full ACL preview"}),e("pre",{class:"acl-preview",children:N})]})]}),A?e("p",{class:"form-error",role:"alert",children:A}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:g,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:k||!s,title:s?"":"VPP must be ready to apply interface ACLs.",children:k?"Applying\u2026":"Apply ACLs"})]})]})})}var Ti="[A-Za-z0-9][A-Za-z0-9._\\-]*";function da({networks:t,vms:n,onClose:s,onSaved:l}){let[a,o]=d(""),[f,r]=d(t[0]?String(t[0].id):""),[b,w]=d(""),[u,k]=ke("interface"),[h,A]=d(!1),[I,m]=d(""),g=te(null),P=te(null),L=te(null);z(()=>{g.current?.showModal(),P.current?.focus()},[]);let S=()=>{h||g.current?.close()},N=async p=>{if(p.preventDefault(),!(h||!L.current?.reportValidity())){m(""),A(!0);try{let T=await V("/api/interfaces",{method:"POST",body:JSON.stringify({name:a.trim(),network_id:Number(f),attached_vm_id:b||null})});await Ce("interface",T.id,T.name,u),l(T)}catch(T){m(T instanceof Error?T.message:String(T))}finally{A(!1)}}};return e("dialog",{ref:g,id:"interface-dialog","aria-labelledby":"interface-dialog-title",onClose:s,onClick:p=>{p.target===p.currentTarget&&S()},onCancel:p=>{h&&p.preventDefault()},children:e("form",{ref:L,novalidate:!0,onSubmit:p=>{N(p)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP vhost-user"}),e("h2",{id:"interface-dialog-title",children:"Add virtual interface"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:S,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:P,type:"text",required:!0,maxlength:64,pattern:Ti,autocomplete:"off",spellcheck:!1,placeholder:"vm01-eth0",value:a,onInput:p=>o(p.currentTarget.value)}),e("span",{class:"field-hint",children:"Creates a vhost-user socket under the configured VPP directory."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Rookery Network"}),e("select",{required:!0,value:f,onChange:p=>r(p.currentTarget.value),children:t.map(p=>e("option",{value:String(p.id),children:[p.name," \xB7 bd ",p.bridge_domain_id]},p.id))})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Attach to VM ",e("em",{children:"optional"})]}),e("select",{value:b,onChange:p=>w(p.currentTarget.value),children:[e("option",{value:"",children:"Leave unattached"}),n.map(p=>{let T=p.status==="running"||p.status==="starting";return e("option",{value:p.id,children:T?`${p.name} (running \u2014 stop first)`:p.name},p.id)})]}),e("span",{class:"field-hint",children:"Stop the VM before attaching or detaching interfaces."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:u,onChange:k,disabled:h})})]}),I?e("p",{class:"form-error",role:"alert",children:I}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:S,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h,children:h?"Creating\u2026":"Add interface"})]})]})})}function ca({iface:t,tables:n,onClose:s,onSaved:l}){let[a,o]=d(String(t.ipv4_table_id??0)),[f,r]=d(String(t.ipv6_table_id??0)),[b,w]=d(!1),[u,k]=d(""),h=te(null);z(()=>{h.current?.showModal()},[]);let A=()=>{b||h.current?.close()},I=async g=>{if(g.preventDefault(),!b){k(""),w(!0);try{let P=await V(`/api/interfaces/${t.id}/route-tables`,{method:"PUT",body:JSON.stringify({ipv4_table_id:Number(a),ipv6_table_id:Number(f)})});l(P)}catch(P){k(P instanceof Error?P.message:String(P))}finally{w(!1)}}},m=g=>n.filter(P=>P.ipv6===g&&P.table_id!==0);return e("dialog",{ref:h,id:"interface-route-tables-dialog","aria-labelledby":"interface-route-tables-title",onClose:s,onClick:g=>{g.target===g.currentTarget&&A()},onCancel:g=>{b&&g.preventDefault()},children:e("form",{novalidate:!0,onSubmit:g=>{I(g)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface FIB binding"}),e("h2",{id:"interface-route-tables-title",children:["Route tables \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:A,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name," \xB7 ",t.mac,". Table 0 is the default VRF."]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 FIB"}),e("select",{value:a,onChange:g=>o(g.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),m(!1).map(g=>e("option",{value:String(g.table_id),children:[g.name," (",g.table_id,")"]},`v4-${g.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 FIB"}),e("select",{value:f,onChange:g=>r(g.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),m(!0).map(g=>e("option",{value:String(g.table_id),children:[g.name," (",g.table_id,")"]},`v6-${g.id}`))]})]})]}),u?e("p",{class:"form-error",role:"alert",children:u}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:b,onClick:A,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:b,children:b?"Applying\u2026":"Apply route tables"})]})]})})}var Pi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function ua({network:t,onClose:n,onSaved:s}){let l=!!t,[a,o]=d(t?.name||""),[f,r]=d(t?.ipv4_cidr||"10.0.0.0/16"),[b,w]=d(t?.ipv6_cidr||"fd00:20::/64"),[u,k]=ke("network",t?.id),[h,A]=d(!1),[I,m]=d(""),g=te(null),P=te(null),L=te(null);z(()=>{g.current?.showModal(),P.current?.focus()},[]);let S=()=>{h||g.current?.close()},N=async p=>{if(p.preventDefault(),!(h||!L.current?.reportValidity())){m(""),A(!0);try{let T=await V(l&&t?`/api/networks/${t.id}`:"/api/networks",{method:l?"PUT":"POST",body:JSON.stringify(l?{ipv4_cidr:f.trim(),ipv6_cidr:b.trim()}:{name:a.trim(),ipv4_cidr:f.trim(),ipv6_cidr:b.trim()})});await Ce("network",T.id,T.name,u),s(T)}catch(T){m(T instanceof Error?T.message:String(T))}finally{A(!1)}}};return e("dialog",{ref:g,id:"network-dialog","aria-labelledby":"network-dialog-title",onClose:n,onClick:p=>{p.target===p.currentTarget&&S()},onCancel:p=>{h&&p.preventDefault()},children:e("form",{ref:L,novalidate:!0,onSubmit:p=>{N(p)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP bridge domain"}),e("h2",{id:"network-dialog-title",children:l?"Edit Rookery Network":"Create Rookery Network"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:S,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:P,type:"text",required:!0,maxlength:64,pattern:Pi,autocomplete:"off",spellcheck:!1,placeholder:"lab-net",value:a,disabled:l,onInput:p=>o(p.currentTarget.value)}),e("span",{class:"field-hint",children:l?"The network name is immutable.":"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 network CIDR"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"10.0.0.0/16",value:f,onInput:p=>r(p.currentTarget.value)}),e("span",{class:"field-hint",children:["Address range for this network; .1 is the BVI and .2 the VNF stack.",l?" CIDRs can only be changed while the network has no VNFs, host taps, or BVI.":""]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 network CIDR"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"fd00:20::/64",value:b,onInput:p=>w(p.currentTarget.value)}),e("span",{class:"field-hint",children:"IPv6 range reserved for this network."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:u,onChange:k,disabled:h})})]}),I?e("p",{class:"form-error",role:"alert",children:I}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:S,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h,children:h?"Creating\u2026":"Create network"})]})]})})}function pa({networks:t,onClose:n,onSaved:s}){let[l,a]=d(t[0]?String(t[0].id):""),[o,f]=d(t[1]?String(t[1].id):""),[r,b]=ke("network_pipe"),[w,u]=d(!1),[k,h]=d(""),A=te(null),I=te(null);z(()=>{A.current?.showModal()},[]);let m=()=>{w||A.current?.close()},g=async P=>{if(P.preventDefault(),!(w||!I.current?.reportValidity())){if(!l||!o||l===o){h("Choose two different networks.");return}u(!0),h("");try{let L=await V("/api/network-pipes",{method:"POST",body:JSON.stringify({network_a_id:Number(l),network_b_id:Number(o)})});await Ce("network_pipe",L.id,`pipes for ${L.network_a_name} and ${L.network_b_name}`,r),s(L)}catch(L){h(L instanceof Error?L.message:String(L))}finally{u(!1)}}};return e("dialog",{ref:A,id:"network-pipe-dialog","aria-labelledby":"network-pipe-title",onClose:n,onClick:P=>{P.target===P.currentTarget&&m()},onCancel:P=>{w&&P.preventDefault()},children:e("form",{ref:I,novalidate:!0,onSubmit:P=>{g(P)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Bridge-domain pipe"}),e("h2",{id:"network-pipe-title",children:"Connect two networks"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("p",{class:"field-hint",children:"Creates a VPP pipe pair and places one end in each bridge domain. This bridges L2 traffic between the two Rookery Networks."}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network A"}),e("select",{required:!0,value:l,onChange:P=>a(P.currentTarget.value),children:t.map(P=>e("option",{value:String(P.id),children:P.name},P.id))})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network B"}),e("select",{required:!0,value:o,onChange:P=>f(P.currentTarget.value),children:t.map(P=>e("option",{value:String(P.id),children:P.name},P.id))})]}),e("div",{class:"field field-wide",children:e(Se,{labels:r,onChange:b,disabled:w})})]}),t.length<2?e("p",{class:"field-hint",children:"Create at least two networks before adding a pipe."}):null,k?e("p",{class:"form-error",role:"alert",children:k}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:w,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:w||t.length<2,children:w?"Creating\u2026":"Create pipe"})]})]})})}var Ei={interface:"any",direction:"both",maxMB:10,snapLength:2048};function Ht(t){return t===void 0||!Number.isFinite(t)||t<0?"\u2014":t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KiB`:t<1024*1024*1024?`${(t/(1024*1024)).toFixed(1)} MiB`:`${(t/(1024*1024*1024)).toFixed(2)} GiB`}function yn(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?t:n.toLocaleString()}function Li(t){switch(t){case"rx":return"RX only";case"tx":return"TX only";case"both":case"rx/tx":return"RX + TX";default:return t||"\u2014"}}function fa(t){return`/api/troubleshoot/capture/files/${encodeURIComponent(t)}`}function ma(){let[t,n]=d([]),[s,l]=d(null),[a,o]=d([]),[f,r]=d(Ei),[b,w]=d(!0),[u,k]=d(!1),[h,A]=d(!1),[I,m]=d(""),[g,P]=d(""),L=Math.min(1e6,Math.max(1,Math.ceil(f.maxMB*1024*1024/(f.snapLength+16)))),S=L*(f.snapLength+16),N=f.maxMB>=1&&f.maxMB<=100&&f.snapLength>=64&&f.snapLength<=9e3,p=!!s?.active,T=async()=>{let x=await V("/api/troubleshoot/capture/files"),R=Array.isArray(x)?x:[];o(R)},H=async()=>{w(!0),P("");let[x,R]=await Promise.allSettled([V("/api/troubleshoot/capture/interfaces"),V("/api/troubleshoot/capture")]);if(x.status==="fulfilled"?n(Array.isArray(x.value)?x.value:[]):P(x.reason instanceof Error?x.reason.message:String(x.reason)),R.status==="fulfilled"){let F=R.value;l(F.status??null),o(Array.isArray(F.files)?F.files:[])}else{g||P(R.reason instanceof Error?R.reason.message:String(R.reason));try{await T()}catch{}}w(!1)};z(()=>{H()},[]),z(()=>{if(!p)return;let x=!1,R=window.setInterval(async()=>{try{let F=await V("/api/troubleshoot/capture");if(x)return;let W=Array.isArray(F.files)?F.files:[];l(F.status??null),o(W),P("")}catch(F){x||P(F instanceof Error?F.message:String(F))}},1500);return()=>{x=!0,window.clearInterval(R)}},[p]);let G=async()=>{if(N){k(!0),P("");try{let x=await V("/api/troubleshoot/capture",{method:"POST",body:JSON.stringify({interface:f.interface,direction:f.direction,max_bytes:Math.round(f.maxMB*1024*1024),snap_length:f.snapLength})});l(x),oe("Packet capture started")}catch(x){let R=x instanceof Error?x.message:String(x);P(R),oe(R,!0)}finally{k(!1)}}},J=async x=>{if(!(p&&s?.file===x.name)&&window.confirm(`Delete ${x.name}?`)){m(x.name),P("");try{await V(fa(x.name),{method:"DELETE"}),o(R=>R.filter(F=>F.name!==x.name)),l(R=>R&&R.file===x.name?{...R,file:""}:R),oe("Packet capture deleted")}catch(R){let F=R instanceof Error?R.message:String(R);P(F),oe(F,!0)}finally{m("")}}},ne=async()=>{A(!0),P("");try{let x=await V("/api/troubleshoot/capture/stop",{method:"POST"});l(x);try{await T()}catch{}oe("Packet capture stopped")}catch(x){let R=x instanceof Error?x.message:String(x);P(R),oe(R,!0)}finally{A(!1)}};return e("div",{class:"capture-panel",children:[e("div",{class:"capture-toolbar",children:[e("span",{class:p?"capture-badge active":"capture-badge",children:p?"Capturing":"Idle"}),e("button",{class:"button ghost",type:"button",disabled:b||p||u||h,onClick:()=>{H()},children:"Refresh"})]}),g?e("div",{class:"banner",role:"alert",children:g}):null,e("div",{class:"capture-form",children:[e("label",{children:[e("span",{children:"Interface"}),e("select",{value:f.interface,disabled:p,onChange:x=>r(R=>({...R,interface:x.currentTarget.value})),children:[e("option",{value:"any",children:"Any interface"}),t.map(x=>e("option",{value:x.name,children:[x.name," \xB7 #",x.sw_if_index,x.type?` \xB7 ${x.type}`:"",x.up?"":" \xB7 down"]},x.name))]})]}),e("label",{children:[e("span",{children:"Direction"}),e("select",{value:f.direction,disabled:p,onChange:x=>r(R=>({...R,direction:x.currentTarget.value})),children:[e("option",{value:"both",children:"RX + TX"}),e("option",{value:"rx",children:"RX only"}),e("option",{value:"tx",children:"TX only"})]})]}),e("label",{children:[e("span",{children:"Max size (MB)"}),e("input",{type:"number",min:1,max:100,step:1,value:f.maxMB,disabled:p,onChange:x=>r(R=>({...R,maxMB:Number(x.currentTarget.value)}))})]}),e("label",{children:[e("span",{children:"Snap length (bytes)"}),e("input",{type:"number",min:64,max:9e3,step:64,value:f.snapLength,disabled:p,onChange:x=>r(R=>({...R,snapLength:Number(x.currentTarget.value)}))})]})]}),e("p",{class:"field-hint",children:["Approx. ",Ht(S)," /"," ",L.toLocaleString()," packets. VPP keeps up to the computed packet count and writes the pcap under the host\u2019s ",e("code",{children:"/tmp"}),"; only traffic on the selected interface is captured."]}),e("div",{class:"capture-actions",children:[p?e("button",{class:"button primary",type:"button",disabled:h,onClick:()=>{ne()},children:h?"Stopping\u2026":"Stop capture"}):e("button",{class:"button primary",type:"button",disabled:!N||u||b,onClick:()=>{G()},children:u?"Starting\u2026":"Start capture"}),s?.file?e("span",{class:"capture-current-file",children:[p?"Writing":"Last capture",": ",e("code",{children:s.file})]}):null]}),s?e("div",{class:p?"capture-status active":"capture-status",children:[e("div",{class:"capture-status-head",children:[e("strong",{children:p?"Capture running":"No capture running"}),s.interface?e("span",{children:s.interface}):null]}),e("div",{class:"capture-status-grid",children:[e("span",{children:[e("b",{children:"Direction"}),Li(s.direction)]}),e("span",{children:[e("b",{children:"Size"}),Ht(s.size)]}),e("span",{children:[e("b",{children:p?"Buffer":"Captured"}),p?`${Ye(s.buffered)} / ${Ye(s.max_packets)} packets`:`${Ye(s.packets)} packets`]}),e("span",{children:[e("b",{children:"Limit"}),Ye(s.max_packets)," packets \xB7 ",Ht(s.max_bytes)]}),e("span",{children:[e("b",{children:"Started"}),yn(s.started_at)]}),s.stopped_at?e("span",{children:[e("b",{children:"Stopped"}),yn(s.stopped_at)]}):null]}),s.raw?e("details",{class:"capture-raw",children:[e("summary",{children:"VPP output"}),e("pre",{children:s.raw})]}):null]}):null,e("div",{class:"capture-files",children:[e("div",{class:"capture-files-head",children:[e("h3",{children:"Captures"}),e("span",{children:[a.length," file",a.length===1?"":"s"]})]}),a.length===0?e("p",{class:"field-hint",children:"No captures yet. Start a capture, then stop it to inspect the pcap."}):e("div",{class:"table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{children:"File"}),e("th",{children:"Size"}),e("th",{children:"Modified"}),e("th",{children:"Actions"})]})}),e("tbody",{children:a.map(x=>e("tr",{children:[e("td",{children:e("code",{children:x.name})}),e("td",{children:Ht(x.size)}),e("td",{children:yn(x.modified)}),e("td",{children:e("div",{class:"capture-file-actions",children:[e("a",{class:"button ghost",href:fa(x.name),download:!0,children:"Download"}),e("button",{class:"button ghost danger",type:"button",disabled:I===x.name||p&&s?.file===x.name,title:p&&s?.file===x.name?"Stop the capture before deleting it":void 0,onClick:()=>{J(x)},children:I===x.name?"Deleting\u2026":"Delete"})]})})]},x.name))})]})})]})]})}var Ii=/^\s*Packet\s+(\d+)\s*$/,ha=/^\s*\d{2}:\d{2}:\d{2}:\d+:\s+(\S+)/;function Ai(t){let n=t.replace(/\r/g,"").split(` -`),s=[],l=null;for(let a of n){let o=a.match(Ii);if(o){l&&s.push({...l,raw:l.lines.join(` -`).trim()}),l={number:Number(o[1]),nodes:[],lines:[a]};continue}if(!l)continue;l.lines.push(a);let f=a.match(ha);f&&l.nodes.push(f[1])}if(l&&s.push({...l,raw:l.lines.join(` -`).trim()}),s.length===0){let a=n.map(o=>o.match(ha)?.[1]).filter(o=>!!o);a.length>0&&s.push({number:1,nodes:a,raw:t.trim()})}return s}function Ni({packet:t}){return t.nodes.length===0?e("p",{class:"empty-state",children:"No VPP node path found for this packet."}):e("div",{class:"trace-path-panel",children:[e("div",{class:"trace-path-head",children:[e("strong",{children:["Packet ",t.number]}),e("span",{class:"muted",children:[t.nodes.length," nodes"]})]}),e("ol",{class:"trace-path","aria-label":`Packet ${t.number} node path`,children:t.nodes.map((n,s)=>e("li",{children:[e("span",{class:"trace-path-node","data-drop":n.includes("drop")?"true":void 0,children:n}),s{L(!0);try{let F=await V("/api/troubleshoot/trace/nodes"),W=Array.isArray(F)?F:Array.isArray(F.nodes)?F.nodes:[];g(W),N("")}catch(F){N(F instanceof Error?F.message:String(F))}finally{L(!1)}};z(()=>{t&&H()},[t]),z(()=>{n||m.length===0||s(m[0])},[n,m]);let G=async F=>{if(F.preventDefault(),!(r||!n.trim())){I(""),b(!0),f(null);try{let W=await V("/api/troubleshoot/trace",{method:"POST",body:JSON.stringify({node:n.trim(),count:Number(l)})});f({...W,raw:""})}catch(W){I(W instanceof Error?W.message:String(W))}finally{b(!1)}}},J=async()=>{I(""),u(!0);try{let F=await V("/api/troubleshoot/trace");f(W=>({...F,node:W?.node??n,count:W?.count??Number(l)}))}catch(F){I(F instanceof Error?F.message:String(F))}finally{u(!1)}},ne=We(()=>Ai(o?.raw??""),[o?.raw]);z(()=>{T(0)},[o?.raw]);let x=ne[p],R=async()=>{I(""),h(!0);try{await V("/api/troubleshoot/trace",{method:"DELETE"}),f(null)}catch(F){I(F instanceof Error?F.message:String(F))}finally{h(!1)}};return e("div",{class:"trace-panel",children:[e("form",{class:"trace-form",novalidate:!0,onSubmit:F=>{G(F)},children:[e("div",{class:"field trace-node-field",children:[e("span",{class:"field-label",children:"VPP graph node"}),e("select",{class:"trace-node-list",required:!0,value:n,onChange:F=>s(F.currentTarget.value),children:[e("option",{value:"",disabled:!0,children:"Select a VPP node\u2026"}),m.map(F=>e("option",{value:F,children:F},F))]}),e("span",{class:"field-hint",children:m.length>0?`${m.length} VPP node${m.length===1?"":"s"} available.`:S||"No VPP nodes loaded yet."}),e("button",{class:"row-action trace-refresh-nodes",type:"button",disabled:P,onClick:()=>{H()},children:P?"Loading nodes\u2026":"Refresh nodes"})]}),e("label",{class:"field trace-count-field",children:[e("span",{class:"field-label",children:"Packet count"}),e("input",{type:"number",required:!0,min:1,max:1e3,value:l,onInput:F=>a(F.currentTarget.value)}),e("span",{class:"field-hint",children:"Trace up to 1,000 packets."})]}),e("button",{class:"button primary",type:"submit",disabled:r||!n.trim()||Number(l)<1||Number(l)>1e3,children:r?"Starting\u2026":"Start trace"}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:()=>{J()},children:w?"Loading\u2026":"Show trace"}),e("button",{class:"button ghost",type:"button",disabled:k,onClick:()=>{R()},children:k?"Clearing\u2026":"Clear"})]}),e("p",{class:"field-hint",children:["Start a trace, generate traffic through the selected node, then click ",e("strong",{children:"Show trace"}),"."]}),A?e("div",{class:"banner",role:"alert",children:A}):null,o?e("div",{class:"trace-result",children:[e("div",{class:"trace-result-head",children:[e("span",{children:o.node?`${o.node} \xB7 ${o.count||0} packets`:"VPP trace"}),o.captured_at?e("span",{class:"muted",children:new Date(o.captured_at).toLocaleString()}):null]}),ne.length>0?e("div",{class:"trace-visual",children:[e("label",{class:"field trace-packet-field",children:[e("span",{class:"field-label",children:"Packet"}),e("select",{value:String(p),onChange:F=>T(Number(F.currentTarget.value)),children:ne.map((F,W)=>e("option",{value:String(W),children:["Packet ",F.number," \xB7 ",F.nodes.length," nodes"]},`${F.number}-${W}`))}),e("span",{class:"field-hint",children:"Select a packet to draw its VPP node path."})]}),x?e(Ni,{packet:x}):null]}):e("p",{class:"empty-state",children:"No trace packets captured yet. Generate traffic and click Show trace."}),e("details",{class:"trace-raw",children:[e("summary",{children:"Raw trace text"}),e("pre",{class:"trace-output",children:o.raw||"No trace packets captured yet. Generate traffic and click Show trace."})]})]}):e("p",{class:"empty-state",children:"No trace loaded."})]})}function ga({table:t,routeID:n,interfaces:s,bviInterfaces:l,hostInterfaces:a,hostTaps:o,tables:f,onBack:r,onSaved:b}){let w=n!==void 0,[u,k]=d(w),[h,A]=d(!1),[I,m]=d(""),[g,P]=d(t.ipv6?"2001:db8::/64":"10.0.0.0/24"),[L,S]=d(""),[N,p]=d(t.ipv6?"2001:db8::1":"10.0.0.1"),[T,H]=d(""),[G,J]=d(""),[ne,x]=d(""),[R,F]=d(""),[W,Y]=d(""),[y,ae]=d(""),[E,M]=d(""),[se,ce]=d(""),[v,$]=d(""),[C,U]=d(""),[de,ie]=d(!1),[pe,ee]=d(!1),[ue,me]=d(""),[c,_]=d(""),[B,X]=d(""),[Z,fe]=d(!1),[Ie,le]=ke("route",n);z(()=>{document.title=`${w?"Edit":"Add"} route \xB7 ${t.name} \xB7 rookery`},[w,t.name]),z(()=>{if(n===void 0)return;let K=!1;return k(!0),V(`/api/route-tables/${t.id}/routes?ipv6=${t.ipv6}`).then($e=>{if(K)return;let O=(Array.isArray($e)?$e:[]).find(_e=>_e.id===n);if(!O){m("Route not found.");return}P(O.prefix),S(O.description||""),p(O.next_hop||""),O.interface_id?(H(String(O.interface_id)),J("")):(H(""),J(O.egress_interface||"")),x(O.preference===void 0?"":String(O.preference)),F(O.count===void 0?"":String(O.count)),Y(O.next_hop_table===void 0?"":String(O.next_hop_table)),ae(O.weight===void 0?"":String(O.weight)),M(O.udp_encap===void 0?"":String(O.udp_encap)),ce(O.ipv4_lookup_table===void 0?"":String(O.ipv4_lookup_table)),$(O.ipv6_lookup_table===void 0?"":String(O.ipv6_lookup_table)),U(O.mpls_lookup_table===void 0?"":String(O.mpls_lookup_table)),ie(!!O.resolve_via_host),ee(!!O.resolve_via_connected),me(O.rx_ipv4_interface||""),_(O.rx_ipv6_interface||""),X((O.out_labels||[]).join(" ")),fe(!!O.drop),m("")}).catch($e=>m($e instanceof Error?$e.message:String($e))).finally(()=>{K||k(!1)}),()=>{K=!0}},[n,t.id,t.ipv6]);let be=async K=>{if(K.preventDefault(),h||u)return;if(!g.trim()){m("Enter a destination prefix.");return}if(ue.trim()&&c.trim()){m("rx-ip4 and rx-ip6 are mutually exclusive.");return}if(!(N.trim()||T||G.trim()||Z||ue.trim()||c.trim()||W||E||se||v||C)){m("Enter a next hop, egress interface, rx interface, lookup option, or drop.");return}let O=T.startsWith("vpp:")?T.slice(4):"",_e=G.trim()||O,Ee=T&&!O&&!G.trim()?Number(T):0,q=B.split(/[\s,]+/).map(Ae=>Ae.trim()).filter(Boolean).map(Ae=>Math.trunc(Number(Ae))).filter(Ae=>Number.isFinite(Ae)&&Ae>=0),we=Ae=>{let ft=Ae.trim();if(!ft)return;let it=Math.max(0,Math.trunc(Number(ft)));return Number.isFinite(it)?it:void 0},Te=we(ne),xe=we(R),Oe=we(y),et=we(E),Ke=we(C),dt=W.trim(),Ve=se.trim(),Le=v.trim(),Je={ipv6:t.ipv6,prefix:g.trim(),description:L.trim(),next_hop:N.trim(),...Ee?{interface_id:Ee}:{},..._e?{egress_interface:_e}:{},...Te!==void 0?{preference:Te}:{},...xe!==void 0?{count:xe}:{},...dt?{next_hop_table:dt}:{},...Oe!==void 0?{weight:Oe}:{},...et!==void 0?{udp_encap:et}:{},...Ve?{ipv4_lookup_table:Ve}:{},...Le?{ipv6_lookup_table:Le}:{},...Ke!==void 0?{mpls_lookup_table:Ke}:{},resolve_via_host:de,resolve_via_connected:pe,rx_ipv4_interface:ue.trim(),rx_ipv6_interface:c.trim(),out_labels:q,drop:Z};A(!0),m("");try{let Ae;if(n===void 0)Ae=await V(`/api/route-tables/${t.id}/routes`,{method:"POST",body:JSON.stringify(Je)});else{let{ipv6:ft,...it}=Je;Ae=await V(`/api/routes/${n}`,{method:"PUT",body:JSON.stringify({id:n,...it})})}await Ce("route",Ae.id,Ae.prefix,Ie),b()}catch(Ae){m(Ae instanceof Error?Ae.message:String(Ae))}finally{A(!1)}},ve=K=>f.filter($e=>$e.ipv6===K&&$e.table_id!==0);return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",onClick:r,children:["\u2190 Routes \xB7 ",t.name]}),e("p",{class:"eyebrow",children:[t.ipv6?"IPv6":"IPv4"," FIB \xB7 table ",t.table_id]}),e("h2",{children:w?"Edit route":"Add route"}),e("p",{class:"panel-subtitle",children:["Route options map directly to the VPP ",e("code",{children:"ip route"})," command."]})]})}),e("form",{class:"route-page-form",onSubmit:K=>{be(K)},children:[u?e("p",{class:"field-hint",children:"Loading route\u2026"}):null,e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Prefix"}),e("input",{type:"text",required:!0,value:g,onInput:K=>P(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Next hop"}),e("input",{type:"text",value:N,onInput:K=>p(K.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Egress interface"}),e("select",{value:T,onChange:K=>H(K.currentTarget.value),children:[e("option",{value:"",children:"Resolve via table"}),s.map(K=>e("option",{value:String(K.id),children:K.name},K.id)),l.length>0?e("optgroup",{label:"Network gateways (BVI)",children:l.map(K=>e("option",{value:`vpp:${K}`,children:K},`bvi-${K}`))}):null,a.some(K=>K.in_vpp&&K.vpp_name)?e("optgroup",{label:"Host interfaces",children:a.filter(K=>K.in_vpp&&K.vpp_name).map(K=>e("option",{value:`vpp:${K.vpp_name}`,children:[K.vpp_name," (host)"]},`host-${K.name}`))}):null,o.length>0?e("optgroup",{label:"Host taps",children:o.map(K=>e("option",{value:`vpp:${K.name}`,children:K.name},`tap-${K.id}`))}):null]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Other VPP interface"}),e("input",{type:"text",placeholder:"host-eno4",value:G,onInput:K=>J(K.currentTarget.value)}),e("span",{class:"field-hint",children:"Overrides the picker when set."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Metric / preference"}),e("input",{type:"number",min:"0",max:"255",value:ne,onInput:K=>x(K.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Description"}),e("textarea",{rows:2,maxlength:1e3,value:L,onInput:K=>S(K.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Se,{labels:Ie,onChange:le,disabled:h})})]}),e("section",{class:"route-advanced","aria-labelledby":"route-advanced-title",children:[e("h4",{class:"route-advanced-title",id:"route-advanced-title",children:"Advanced VPP route options"}),e("p",{class:"field-hint",children:["Leave an option blank to omit it from the VPP command. Next-hop and IP lookup tables use route-table names; choose ",e("code",{children:"default"}),"for table ",e("code",{children:"0"}),". MPLS lookup uses a numeric table ID."]}),e("div",{class:"route-advanced-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Count"}),e("input",{type:"number",min:"1",max:"100000",placeholder:"1",value:R,onInput:K=>F(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Next-hop table"}),e("select",{value:W,onChange:K=>Y(K.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(t.ipv6).map(K=>e("option",{value:K.name,children:[K.name," (",K.table_id,")"]},`nh-${K.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Weight"}),e("input",{type:"number",min:"0",max:"255",value:y,onInput:K=>ae(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"UDP encap"}),e("input",{type:"number",min:"0",value:E,onInput:K=>M(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 lookup table"}),e("select",{value:se,onChange:K=>ce(K.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(!1).map(K=>e("option",{value:K.name,children:[K.name," (",K.table_id,")"]},`v4-${K.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 lookup table"}),e("select",{value:v,onChange:K=>$(K.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(!0).map(K=>e("option",{value:K.name,children:[K.name," (",K.table_id,")"]},`v6-${K.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"MPLS lookup table ID"}),e("input",{type:"number",min:"0",value:C,onInput:K=>U(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"rx-ip4 interface"}),e("input",{type:"text",value:ue,onInput:K=>me(K.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"rx-ip6 interface"}),e("input",{type:"text",value:c,onInput:K=>_(K.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Out labels"}),e("input",{type:"text",placeholder:"100 200 300",value:B,onInput:K=>X(K.currentTarget.value)})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:de,onChange:K=>ie(K.currentTarget.checked)}),e("span",{children:"resolve-via-host"})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:pe,onChange:K=>ee(K.currentTarget.checked)}),e("span",{children:"resolve-via-connected"})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:Z,onChange:K=>fe(K.currentTarget.checked)}),e("span",{children:"drop"})]})]})]}),I?e("p",{class:"form-error",role:"alert",children:I}):null,e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:r,disabled:h,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h||u,children:h?"Saving\u2026":w?"Save route":"Add route"})]})]})]})}function va({tables:t,onCancel:n,onSaved:s}){let l=Math.max(0,...t.filter(P=>!P.is_default).map(P=>P.table_id))+1,[a,o]=d(""),[f,r]=d(String(l)),[b,w]=d(!1),[u,k]=ke("route_table"),[h,A]=d(!1),[I,m]=d("");z(()=>{document.title="Create route table \xB7 rookery"},[]);let g=async P=>{if(P.preventDefault(),!h){m(""),A(!0);try{let L=await V("/api/route-tables",{method:"POST",body:JSON.stringify({name:a.trim(),table_id:Number(f),ipv6:b})});await Ce("route_table",L.id,L.name,u),s(L)}catch(L){m(L instanceof Error?L.message:String(L))}finally{A(!1)}}};return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",onClick:n,children:"\u2190 Route tables"}),e("p",{class:"eyebrow",children:"IP FIB / VRF"}),e("h2",{children:"Create route table"}),e("p",{class:"panel-subtitle",children:"Define an IPv4 or IPv6 VRF/FIB table. Table 0 is the built-in default VRF."})]})}),e("form",{class:"route-page-form",novalidate:!0,onSubmit:P=>{g(P)},children:[e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{type:"text",required:!0,maxLength:63,placeholder:"tenant-blue",value:a,onInput:P=>o(P.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Table ID"}),e("input",{type:"number",required:!0,min:1,value:f,onInput:P=>r(P.currentTarget.value)}),e("span",{class:"field-hint",children:"Custom tables start at 1; table 0 is the default VRF."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:b,onChange:P=>w(P.currentTarget.checked)}),e("span",{children:"IPv6 FIB table"})]}),e("span",{class:"field-hint",children:"The same table ID can be used separately for IPv4 and IPv6."})]}),e("div",{class:"field field-wide",children:e(Se,{labels:u,onChange:k,disabled:h})})]}),I?e("p",{class:"form-error",role:"alert",children:I}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:n,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h||!a.trim()||Number(f)<1,children:h?"Creating\u2026":"Create route table"})]})]})]})}function Di(t){let n=[];return(t.count||0)>1&&n.push(`count ${t.count}`),t.next_hop_table&&n.push(`next-hop-table ${t.next_hop_table}`),t.weight&&n.push(`weight ${t.weight}`),t.udp_encap&&n.push(`udp-encap ${t.udp_encap}`),t.ipv4_lookup_table&&n.push(`ip4-lookup-in-table ${t.ipv4_lookup_table}`),t.ipv6_lookup_table&&n.push(`ip6-lookup-in-table ${t.ipv6_lookup_table}`),t.mpls_lookup_table&&n.push(`mpls-lookup-in-table ${t.mpls_lookup_table}`),t.resolve_via_host&&n.push("resolve-via-host"),t.resolve_via_connected&&n.push("resolve-via-connected"),t.rx_ipv4_interface&&n.push(`rx-ip4 ${t.rx_ipv4_interface}`),t.rx_ipv6_interface&&n.push(`rx-ip6 ${t.rx_ipv6_interface}`),t.out_labels&&t.out_labels.length>0&&n.push(`out-labels ${t.out_labels.join(" ")}`),t.drop&&n.push("drop"),n.join(" \xB7 ")}function ya({table:t,onBack:n,onChanged:s}){let[l,a]=d([]),[o,f]=d([]),[r,b]=d(!0),[w,u]=d(!0),[k,h]=d(""),[A,I]=d(""),m=Ne("route"),g=`/route-tables/${t.id}/routes/new?ipv6=${t.ipv6}`,P=async()=>{b(!0);try{let p=await V(`/api/route-tables/${t.id}/routes?ipv6=${t.ipv6}`);a(Array.isArray(p)?p:[]),h("")}catch(p){h(p instanceof Error?p.message:String(p))}finally{b(!1)}},L=async()=>{u(!0);try{let p=await V(`/api/route-tables/${t.id}/fib?ipv6=${t.ipv6}`);f(Array.isArray(p)?p:[]),I("")}catch(p){I(p instanceof Error?p.message:String(p))}finally{u(!1)}};z(()=>{P()},[t.id,t.ipv6]),z(()=>{L()},[t.id,t.ipv6]),z(()=>{document.title=`Routes \xB7 ${t.name} \xB7 rookery`},[t.name]);let S=async p=>{if(window.confirm(`Delete route ${p.prefix}${p.next_hop?` via ${p.next_hop}`:""}?`)){h("");try{await V(`/api/routes/${p.id}`,{method:"DELETE"}),a(T=>T.filter(H=>H.id!==p.id)),await L(),s()}catch(T){h(T instanceof Error?T.message:String(T))}}},N=o.filter(p=>p.dynamic);return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:[e("div",{children:[e("button",{class:"back-link",type:"button",onClick:n,children:"\u2190 Route tables"}),e("p",{class:"eyebrow",children:[t.ipv6?"IPv6":"IPv4"," FIB \xB7 table ",t.table_id,t.is_default?" \xB7 default VRF":""]}),e("h2",{children:["Routes \xB7 ",t.name]}),e("p",{class:"panel-subtitle",children:"Static routes are managed by Rookery. Dynamic routes are read directly from the live VPP FIB."})]}),e("button",{class:"button primary",type:"button",onClick:()=>Q(g),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add route"]})]}),e("section",{class:"route-page-section","aria-labelledby":"static-routes-title",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h3",{id:"static-routes-title",children:"Static routes"}),e("p",{class:"field-hint",children:"Prefixes installed in this VRF by Rookery."})]})}),k?e("p",{class:"form-error",role:"alert",children:k}):null,e("div",{class:"table-wrap route-table-wrap",children:r?e("p",{class:"empty-state",children:"Loading routes\u2026"}):l.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Next hop"}),e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Options"}),e("th",{scope:"col",children:"Preference"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:l.map(p=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:p.prefix}),p.description?e("span",{class:"vm-description",children:p.description}):null,e(De,{entityType:"route",entityID:p.id,name:p.prefix,labels:m.labels[String(p.id)],onSaved:T=>m.setEntityLabels(String(p.id),T)})]}),e("td",{class:"mono",children:p.drop?"drop":p.next_hop||"attached"}),e("td",{class:"mono",children:p.egress_interface||p.interface_name||"resolved by table"}),e("td",{class:"mono",children:Di(p)||"\u2014"}),e("td",{class:"mono",children:p.preference||0}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>Q(`/route-tables/${t.id}/routes/${p.id}/edit?ipv6=${t.ipv6}`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{S(p)},children:"Delete"})]})})]},p.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2197"}),e("h3",{children:"No static routes"}),e("p",{children:"Add a route to this VRF."}),e("button",{class:"button primary",type:"button",onClick:()=>Q(g),children:"Add route"})]})})]}),e("section",{class:"fib-section route-page-section","aria-labelledby":"fib-routes-title",children:[e("div",{class:"panel-head fib-head",children:[e("div",{children:[e("h3",{id:"fib-routes-title",children:"Dynamic routes"}),e("p",{class:"field-hint",children:"Read-only entries from the live VPP FIB, including connected, DHCP, ND, and adjacency routes."})]}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:()=>{L()},children:w?"Refreshing\u2026":"Refresh FIB"})]}),A?e("p",{class:"form-error",role:"alert",children:A}):w?e("p",{class:"empty-state",children:"Loading VPP FIB\u2026"}):N.length>0?e("div",{class:"table-wrap route-table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",children:"Paths"})]})}),e("tbody",{children:N.map(p=>e("tr",{children:[e("td",{class:"mono",children:p.prefix}),e("td",{children:e("span",{class:"fib-source",children:p.source_name})}),e("td",{children:(p.paths??[]).length>0?(p.paths??[]).map((T,H)=>e("div",{class:"fib-path",children:[e("span",{class:"fib-path-type",children:T.type})," ",e("span",{class:"mono",children:T.next_hop||"\u2014"}),T.interface_name?e("span",{children:[" via ",T.interface_name]}):null]},`${p.prefix}-${H}`)):e("span",{class:"muted",children:"no paths"})})]},`${p.ipv6?"v6":"v4"}-${p.prefix}`))})]})}):e("p",{class:"empty-state",children:"No dynamic routes in this table."})]})]})}function _a({name:t,label:n,hostAddresses:s,onClose:l}){let[a,o]=d(""),[f,r]=d(!1),[b,w]=d([]),[u,k]=d(!1),[h,A]=d(""),[I,m]=d(""),[g,P]=d(!0),[L,S]=d(!1),[N,p]=d(!1),[T,H]=d(""),G=te(null),J=te(null),ne=`/api/vpp/interfaces/${encodeURIComponent(t)}/addresses`,x=y=>{w(y.live_addresses||[]),k(!!y.dhcp_client_configured),A(y.dhcp_client_status||""),m(y.status_detail||"")};z(()=>{G.current?.showModal(),R()},[]);let R=async()=>{P(!0);try{let y=await V(ne);o((y.addresses||[]).join(` -`)),r(!!y.dhcp_client_enabled),x(y),H("")}catch(y){H(y instanceof Error?y.message:String(y))}finally{P(!1),J.current?.focus()}},F=async()=>{if(!L){S(!0);try{let y=await V(ne);x(y),H("")}catch(y){H(y instanceof Error?y.message:String(y))}finally{S(!1)}}},W=()=>{N||G.current?.close()},Y=async y=>{if(y.preventDefault(),N||g)return;p(!0),H("");let ae=a.split(/\r?\n/).map(E=>E.trim()).filter(Boolean);try{let E=await V(ne,{method:"PUT",body:JSON.stringify({addresses:ae,dhcp_client_enabled:f})});o((E.addresses||[]).join(` -`)),r(!!E.dhcp_client_enabled),x(E),oe(`Saved configuration for ${t}`)}catch(E){H(E instanceof Error?E.message:String(E))}finally{p(!1)}};return e("dialog",{ref:G,id:"vpp-addresses-dialog","aria-labelledby":"vpp-addresses-title",onClose:l,onClick:y=>{y.target===y.currentTarget&&W()},onCancel:y=>{N&&y.preventDefault()},children:e("form",{onSubmit:y=>{Y(y)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP interface addresses"}),e("h2",{id:"vpp-addresses-title",children:["Addresses \xB7 ",t]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:W,children:"\xD7"})]}),e("div",{class:"startup-script-status",children:[e("span",{class:"status-badge","data-status":"running",children:n||"VPP interface"}),e("span",{class:"field-hint",children:"IPv4 and IPv6 CIDRs are applied directly to this VPP interface."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:f,onChange:y=>r(y.currentTarget.checked)}),e("span",{children:"Enable DHCP client (IPv4)"})]}),e("span",{class:"field-hint",children:"VPP requests an IPv4 lease from the network. This can be combined with static addresses."})]}),s?e("div",{class:"vpp-address-live field-wide",children:[e("div",{class:"vpp-address-live-head",children:e("span",{class:"field-label",children:"Host side addresses"})}),s.length>0?e("div",{class:"vpp-live-addresses mono",children:s.map(y=>e("span",{children:y},y))}):e("p",{class:"field-hint",children:"No host-side address assigned."})]}):null,e("div",{class:"vpp-address-live field-wide",children:[e("div",{class:"vpp-address-live-head",children:[e("span",{class:"field-label",children:"VPP side addresses"}),e("div",{class:"actions",children:[e("span",{class:"status-badge","data-status":u?"running":"disabled",children:["DHCP client ",u?"configured":"not configured"]}),e("button",{class:"row-action",type:"button",disabled:L||N,onClick:()=>{F()},children:L?"Refreshing\u2026":"Refresh status"})]})]}),I?e("p",{class:"field-hint",children:I}):b.length>0?e("div",{class:"vpp-live-addresses mono",children:b.map(y=>e("span",{children:y},y))}):e("p",{class:"field-hint",children:["No live address assigned yet",f?"; waiting for a DHCP lease.":"."]}),h?e("details",{class:"startup-script-output",children:[e("summary",{children:"DHCP client status"}),e("pre",{children:h})]}):null]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Addresses"}),e("textarea",{ref:J,class:"startup-script-editor",rows:8,spellcheck:!1,placeholder:`10.20.0.10/24 -fd00:20::10/64`,value:a,onInput:y=>o(y.currentTarget.value)}),e("span",{class:"field-hint",children:"One address per line, using CIDR notation. Leave empty to remove all Rookery-managed addresses. Existing addresses managed by other Rookery features are not shown here."})]}),T?e("p",{class:"form-error",role:"alert",children:T}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:N,onClick:W,children:"Close"}),e("button",{class:"button primary",type:"submit",disabled:N||g,children:N?"Saving\u2026":"Save addresses"})]})]})})}function wa({name:t,label:n,tables:s,onClose:l,onSaved:a}){let[o,f]=d("0"),[r,b]=d("0"),[w,u]=d(!0),[k,h]=d(!1),[A,I]=d(""),m=te(null),g=`/api/vpp/interfaces/${encodeURIComponent(t)}/route-tables`;z(()=>{m.current?.showModal(),P()},[]);let P=async()=>{u(!0);try{let p=await V(g);f(String(p.ipv4_table_id??0)),b(String(p.ipv6_table_id??0)),I("")}catch(p){I(p instanceof Error?p.message:String(p))}finally{u(!1)}},L=()=>{k||m.current?.close()},S=async p=>{if(p.preventDefault(),!(w||k)){h(!0),I("");try{let T=await V(g,{method:"PUT",body:JSON.stringify({ipv4_table_id:Number(o),ipv6_table_id:Number(r)})});a(T)}catch(T){I(T instanceof Error?T.message:String(T))}finally{h(!1)}}},N=p=>s.filter(T=>T.ipv6===p&&T.table_id!==0);return e("dialog",{ref:m,id:"vpp-interface-route-tables-dialog","aria-labelledby":"vpp-interface-route-tables-title",onClose:l,onClick:p=>{p.target===p.currentTarget&&L()},onCancel:p=>{k&&p.preventDefault()},children:e("form",{novalidate:!0,onSubmit:p=>{S(p)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface FIB binding"}),e("h2",{id:"vpp-interface-route-tables-title",children:["Route tables \xB7 ",t]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:L,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[n,". Table 0 is the default VRF. Saving an explicit binding, including default (0), overrides any automatic network VRF for a host tap and may affect reachability."]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 FIB"}),e("select",{value:o,disabled:w||k,onChange:p=>f(p.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),N(!1).map(p=>e("option",{value:String(p.table_id),children:[p.name," (",p.table_id,")"]},`v4-${p.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 FIB"}),e("select",{value:r,disabled:w||k,onChange:p=>b(p.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),N(!0).map(p=>e("option",{value:String(p.table_id),children:[p.name," (",p.table_id,")"]},`v6-${p.id}`))]})]})]}),A?e("p",{class:"form-error",role:"alert",children:A}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:L,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:w||k,children:k?"Applying\u2026":"Apply route tables"})]})]})})}var wn=["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#b10026"],ka=92,Ca=104,yt=60,Sa=900,xi=220,Vi=/^(?:vhost-user-input|tap-input|af-packet-input|interface-output|interface-\d+-(?:output|tx)(?:-deleted)?|(?:bvi|host|vm|vnf)[^ ]*-(?:output|tx)(?:-deleted)?)$/;function Mi(t){let n=/^(.*?)-(?:output|tx)(?:-deleted)?$/.exec(t);return n?n[1]:t}function $i(t){return t==="vhost-user-input"?"vhost-user input":t==="tap-input"?"tap input":t==="af-packet-input"?"af-packet input":t==="interface"?"interface (generic)":/^interface-\d+$/.test(t)?`${t} (deleted)`:t.startsWith("vm-")?`${t} (VM interface)`:/^vnf/.test(t)?`${t} (VNF tap)`:/^bvi/.test(t)?`${t} (BVI)`:t.startsWith("host-")?`${t} (host NIC)`:t}function Ri(t){let n=new Map;for(let s of t.nodes){if(!s.isInterface)continue;let l=Mi(s.id),a=n.get(l);a?a.push(s.id):n.set(l,[s.id])}return Array.from(n.entries()).map(([s,l])=>({id:s,label:$i(s),nodeIDs:l})).sort((s,l)=>s.label.localeCompare(l.label))}function Fi(t){let n=[],s=(l,a,o)=>{let f=t.nodes.filter(r=>o(r.id)).map(r=>r.id);f.length>0&&n.push({id:l,label:a,nodeIDs:f})};return s("feature:acl","ACL plugin",l=>l.startsWith("acl-plugin")),s("feature:nat","NAT44",l=>l.startsWith("nat")),s("feature:dhcp","DHCP",l=>l.includes("dhcp")),n}function Ta(t){let n=new Map;for(let s of t.nodes)n.set(s.id,new Set([s.id]));for(let s of t.edges)n.get(s.source)?.add(s.target),n.get(s.target)?.add(s.source);return n}function Pa(t){let n=Array.isArray(t.nodes)?t.nodes:[],s=Array.isArray(t.edges)?t.edges:[],l={nodes:[],edges:[],width:Sa,height:420,maxRank:0,maxLayerCount:0,backwardCount:0,interfaceCount:0};if(n.length===0)return l;let a=new Map;n.forEach((C,U)=>a.set(C.id,U));let o=n.length,f=Array.from({length:o},()=>[]),r=Array.from({length:o},()=>[]),b=[],w=new Set;for(let C of s){let U=a.get(C.source),de=a.get(C.target);if(U===void 0||de===void 0)continue;let ie=`${U}\0${de}`;w.has(ie)||(w.add(ie),b.push({sourceIndex:U,targetIndex:de,source:C.source,target:C.target}),U!==de&&(f[U].push(de),r[de].push(U)))}let u=new Set;for(let C=0;C0&&f[C].every(U=>u.has(U))&&k.add(C);let h=[];for(let C=0;CA.has(U))),m.set(C,r[C].filter(U=>A.has(U)));let g=new Map,P=new Set,L=(C,U)=>{let de=[C];P.add(C),g.set(C,U);for(let ie=0;ie(m.get(C)?.length??0)===0);S.length===0&&h.length>0&&S.push(h[0]);for(let C of S)P.has(C)||L(C,0);for(let C of h)P.has(C)||L(C,0);let N=0;for(let C of g.values())N=Math.max(N,C);let p=N+1,T=N+(k.size>0?2:1),H=new Array(o).fill(0);for(let C of h)H[C]=g.get(C)??0;for(let C of k)H[C]=p;for(let C of u)H[C]=T;let G=0;for(let C of H)G=Math.max(G,C);let J=Array.from({length:G+1},()=>[]);for(let C=0;Cn[C].label||n[C].id;J.forEach(C=>{C.sort((U,de)=>ne(U).localeCompare(ne(de)))});let x=new Array(o).fill(0),R=()=>{J.forEach(C=>{C.forEach((U,de)=>{x[U]=de})})};R();let F=C=>{let U=J[H[C]];return U.length<=1?.5:x[C]/(U.length-1)};for(let C=0;C<5;C+=1){for(let U=1;U<=G;U+=1){let de=J[U],ie=new Map;for(let pe of de){let ee=r[pe].filter(ue=>H[ue]0?ee.reduce((ue,me)=>ue+F(me),0)/ee.length:F(pe))}de.sort((pe,ee)=>(ie.get(pe)??0)-(ie.get(ee)??0)||ne(pe).localeCompare(ne(ee))),R()}for(let U=G-1;U>=0;U-=1){let de=J[U],ie=new Map;for(let pe of de){let ee=f[pe].filter(ue=>H[ue]>U);ie.set(pe,ee.length>0?ee.reduce((ue,me)=>ue+F(me),0)/ee.length:F(pe))}de.sort((pe,ee)=>(ie.get(pe)??0)-(ie.get(ee)??0)||ne(pe).localeCompare(ne(ee))),R()}}let W=Math.max(...J.map(C=>C.length),1),Y=Math.max(Sa,(W-1)*ka+yt*2),y=Math.max(420,G*Ca+yt*2),ae=new Array(o).fill(Y/2),E=new Array(o).fill(yt);J.forEach((C,U)=>{let de=Math.min(Y-yt*2,Math.max(0,(C.length-1)*ka)),ie=Y/2-de/2;C.forEach((pe,ee)=>{ae[pe]=C.length===1?Y/2:ie+de*ee/(C.length-1),E[pe]=yt+U*Ca})});let M=n.map(C=>Number(C.width)||0),se=Math.min(...M),ce=Math.max(...M),v=n.map((C,U)=>{let de=Number(C.fill)||0,ie=ce>se?(M[U]-se)/(ce-se):.5;return{id:C.id,label:C.label||C.id,x:ae[U],y:E[U],radius:7+ie*11,color:de>=1&&de<=wn.length?wn[de-1]:"#64748b",rank:H[U],isInterface:Vi.test(C.id)}}),$=b.map(C=>({source:C.source,target:C.target,backward:H[C.sourceIndex]>=H[C.targetIndex]}));return{nodes:v,edges:$,width:Y,height:y,maxRank:G,maxLayerCount:W,backwardCount:$.filter(C=>C.backward).length,interfaceCount:v.filter(C=>C.isInterface).length}}function _n(t,n,s){return Math.min(s,Math.max(n,t))}function Ea({graph:t}){let n=We(()=>Pa(t),[t]),[s,l]=d(""),a=We(()=>Ri(n),[n]),o=We(()=>Fi(n),[n]),f=We(()=>[...a,...o],[a,o]),r=We(()=>Ta(n),[n]),b=We(()=>{if(!s)return null;let v=f.find(C=>C.id===s);if(!v)return null;let $=new Set(v.nodeIDs);for(let C of v.nodeIDs)for(let U of r.get(C)??[])$.add(U);return $},[s,f,r]),w=We(()=>b?{...t,nodes:t.nodes.filter(v=>b.has(v.id)),edges:t.edges.filter(v=>b.has(v.source)&&b.has(v.target))}:t,[t,b]),u=We(()=>Pa(w),[w]),[k,h]=d(()=>({x:0,y:0,w:u.width,h:u.height})),[A,I]=d(!1),[m,g]=d(null),[P,L]=d(!1),S=te(null),N=te(null);z(()=>{s&&!f.some(v=>v.id===s)&&l("")},[f,s]),z(()=>{N.current=null,I(!1),g(null),h({x:0,y:0,w:u.width,h:u.height})},[u]);let p=v=>{let{x:C,y:U,w:de,h:ie}=v;return de>=u.width?C=(u.width-de)/2:C=_n(C,-40,u.width-de+40),ie>=u.height?U=(u.height-ie)/2:U=_n(U,-40,u.height-ie+40),{x:C,y:U,w:de,h:ie}},T=(v,$,C)=>{h(U=>{let de=Math.max(xi,u.width*.06),ie=u.width*1.08,ee=_n(U.w/v,de,ie)/U.w,ue=U.w*ee,me=U.h*ee;return p({x:$-($-U.x)*ee,y:C-(C-U.y)*ee,w:ue,h:me})})},H=(v,$)=>{let C=S.current;if(!C)return{x:0,y:0};let U=C.getScreenCTM();if(!U)return{x:0,y:0};let de=C.createSVGPoint();de.x=v,de.y=$;let ie=de.matrixTransform(U.inverse());return{x:ie.x,y:ie.y}},G=()=>{h({x:0,y:0,w:u.width,h:u.height})},J=v=>{v.preventDefault();let $=H(v.clientX,v.clientY);T(Math.exp(-v.deltaY*.0015),$.x,$.y)},ne=v=>{if(v.pointerType==="mouse"&&v.button!==0)return;let $=S.current;if(!$)return;let C=$.getScreenCTM();C&&(v.preventDefault(),$.setPointerCapture(v.pointerId),N.current={pointerId:v.pointerId,startX:v.clientX,startY:v.clientY,startView:k,scaleX:C.a||1,scaleY:C.d||1},I(!0))},x=v=>{let $=N.current;if(!$||$.pointerId!==v.pointerId)return;let C=($.startX-v.clientX)/$.scaleX,U=($.startY-v.clientY)/$.scaleY;h(p({...$.startView,x:$.startView.x+C,y:$.startView.y+U}))},R=v=>{let $=N.current;if(!$||$.pointerId!==v.pointerId)return;N.current=null,I(!1);let C=S.current;C?.hasPointerCapture(v.pointerId)&&C.releasePointerCapture(v.pointerId)},F=We(()=>new Map(u.nodes.map(v=>[v.id,v])),[u]),W=We(()=>Ta(u),[u]);if(u.nodes.length===0)return e("div",{class:"empty vpp-graph-empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No active VPP nodes"}),e("p",{children:"Generate traffic or refresh after VPP has processed packets since the last stats clear."})]});let y=u.nodes.length<=40||P||m!==null,ae=m?W.get(m)??new Set([m]):null,E=m&&ae?Math.max(0,ae.size-1):0,M=Math.max(9,Math.min(26,k.w/70)),se=Math.max(1,Math.round(u.width/k.w*100)),ce=m?F.get(m):null;return e("div",{class:"vpp-graph-wrap",children:[e("div",{class:"vpp-graph-meta",children:[e("span",{children:[u.nodes.length," active node",u.nodes.length===1?"":"s"," \xB7 ",u.edges.length," ","edge",u.edges.length===1?"":"s",u.backwardCount>0?` \xB7 ${u.backwardCount} feedback`:"",u.interfaceCount>0?` \xB7 ${u.interfaceCount} interface${u.interfaceCount===1?"":"s"}`:""]}),e("span",{class:"vpp-graph-legend",children:[e("i",{class:"vpp-legend-node"}),"node = cycles/packet",e("i",{class:"vpp-legend-packet"}),"color = packets/call",e("i",{class:"vpp-legend-interface"}),"ring = interface",ce?` \xB7 ${ce.label}${ce.isInterface?" (interface)":""} \xB7 ${E} connected`:" \xB7 hover to isolate connections"]})]}),e("div",{class:"vpp-graph-toolbar",children:[e("span",{class:"vpp-graph-hint",children:["Inputs at top \xB7 outputs at bottom \xB7 drag to pan \xB7 scroll to zoom",u.backwardCount>0?" \xB7 dashed edge = feedback/self-loop":""]}),e("div",{class:"vpp-graph-controls",children:[f.length>0?e("label",{class:"vpp-interface-picker",children:[e("span",{children:"Focus"}),e("select",{value:s,title:"Show only nodes related to this interface or packet feature",onChange:v=>l(v.currentTarget.value),children:[e("option",{value:"",children:"All nodes"}),a.length>0?e("optgroup",{label:"Interfaces",children:a.map(v=>e("option",{value:v.id,children:v.label},v.id))}):null,o.length>0?e("optgroup",{label:"Packet features",children:o.map(v=>e("option",{value:v.id,children:v.label},v.id))}):null]})]}):null,e("div",{class:"vpp-graph-zoom",children:[e("button",{type:"button","aria-label":"Zoom out",title:"Zoom out",onClick:()=>T(1/1.35,k.x+k.w/2,k.y+k.h/2),children:"\u2212"}),e("span",{children:[se,"%"]}),e("button",{type:"button","aria-label":"Zoom in",title:"Zoom in",onClick:()=>T(1.35,k.x+k.w/2,k.y+k.h/2),children:"+"}),e("button",{type:"button",onClick:G,children:"Fit"}),u.nodes.length>40?e("button",{type:"button","aria-pressed":P,class:P?"is-active":"",onClick:()=>L(v=>!v),children:"Labels"}):null]})]})]}),e("svg",{ref:S,class:`vpp-graph${A?" is-panning":""}`,viewBox:`${k.x} ${k.y} ${k.w} ${k.h}`,role:"img","aria-label":"Active VPP packet processing graph, inputs at top and outputs at bottom",onWheel:J,onPointerDown:ne,onPointerMove:x,onPointerUp:R,onPointerCancel:R,onLostPointerCapture:()=>{N.current=null,I(!1)},children:[e("defs",{children:e("marker",{id:"vpp-graph-arrow",viewBox:"0 0 10 10",refX:"9",refY:"5",markerWidth:"6",markerHeight:"6",orient:"auto-start-reverse",children:e("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"#64748b"})})}),e("g",{class:"vpp-graph-edges",children:u.edges.map((v,$)=>{let C=F.get(v.source),U=F.get(v.target);if(!C||!U)return null;let de=m!==null&&(v.source===m||v.target===m),ie=m!==null&&!de;if(v.source===v.target){let fe=C.radius+6;return e("path",{d:`M ${C.x-C.radius} ${C.y} C ${C.x-fe*2.2} ${C.y-fe*2.4}, ${C.x+fe*2.2} ${C.y-fe*2.4}, ${C.x+C.radius} ${C.y}`,class:`vpp-edge vpp-edge-back${de?" vpp-edge-active":""}${ie?" vpp-edge-hidden":""}`,markerEnd:"url(#vpp-graph-arrow)",vectorEffect:"non-scaling-stroke"},`${v.source}->${v.target}-${$}`)}let pe=U.x-C.x,ee=U.y-C.y,ue=Math.max(1,Math.sqrt(pe*pe+ee*ee)),me=C.x+pe/ue*(C.radius+2),c=C.y+ee/ue*(C.radius+2),_=U.x-pe/ue*(U.radius+5),B=U.y-ee/ue*(U.radius+5),X=B>=c?1:-1,Z=Math.max(24,Math.min(80,Math.abs(B-c)*.45));return e("path",{d:`M ${me} ${c} C ${me} ${c+X*Z}, ${_} ${B-X*Z}, ${_} ${B}`,class:`vpp-edge${v.backward?" vpp-edge-back":""}${de?" vpp-edge-active":""}${ie?" vpp-edge-hidden":""}`,markerEnd:"url(#vpp-graph-arrow)",vectorEffect:"non-scaling-stroke"},`${v.source}->${v.target}-${$}`)})}),e("g",{class:"vpp-graph-nodes",children:u.nodes.map(v=>{let $=v.x<160?"start":v.x>u.width-160?"end":"middle",C=$==="start"?v.x-v.radius:$==="end"?v.x+v.radius:v.x,U=ae===null||ae.has(v.id);return e("g",{class:`${y?"vpp-node":"vpp-node vpp-node-hover-label"}${U?"":" vpp-node-hidden"}`,onMouseEnter:()=>g(v.id),onMouseLeave:()=>g(null),children:[v.isInterface?e("circle",{class:"vpp-interface-ring",cx:v.x,cy:v.y,r:v.radius+4,vectorEffect:"non-scaling-stroke"}):null,e("circle",{cx:v.x,cy:v.y,r:v.radius,fill:v.color,vectorEffect:"non-scaling-stroke",children:e("title",{children:[v.label,v.isInterface?" (interface)":""]})}),e("text",{x:C,y:v.y+v.radius+M*1.25,textAnchor:$,style:{fontSize:`${M}px`},children:v.label})]},v.id)})})]}),e("div",{class:"vpp-graph-palette","aria-hidden":"true",children:[e("span",{children:"packets/call"}),wn.map((v,$)=>e("i",{style:{background:v},title:`${$+1}`},v)),e("span",{class:"vpp-graph-palette-note",children:"YlOrRd"})]})]})}function Ot(t,n){return`pipe${t.instance}.${n}`}function pt(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function Ut(t){let n=Math.trunc(Number(t)||0);return n>=1024**3?`${Math.trunc(n/1024**3)} GB`:n>=1024**2?`${Math.trunc(n/1024**2)} MB`:n>=1024?`${Math.trunc(n/1024)} KB`:`${n} B`}var kn=!1,La=0,Ia=[{id:"hosts",label:"Host interfaces"},{id:"hosttaps",label:"Host taps"},{id:"networks",label:"Networks"},{id:"pipes",label:"BD pipes"},{id:"interfaces",label:"Interfaces"},{id:"acl",label:"ACLs"},{id:"routes",label:"Route tables"},{id:"troubleshoot",label:"Troubleshoot"}];function Aa(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d([]),[f,r]=d([]),[b,w]=d([]),[u,k]=d([]),[h,A]=d([]),[I,m]=d([]),[g,P]=d(!1),[L,S]=d(!0),[N,p]=d(""),[T,H]=d(""),[G,J]=d(""),[ne,x]=d(!1),[R,F]=d(""),[W,Y]=d(""),[y,ae]=d(""),[E,M]=d(!1),[se,ce]=d(""),[v,$]=d(""),[C,U]=d(""),[de,ie]=d(!1),[pe,ee]=d(null),[ue,me]=d(!1),[c,_]=d(null),[B,X]=d(null),[Z,fe]=d(null),[Ie,le]=d(null),[be,ve]=d(null),[K,$e]=d([]),[O,_e]=d(""),[Ee,q]=d(!1),[we,Te]=d(null),[xe,Oe]=d(null),[et,Ke]=d(null),[dt,Ve]=d(null),[Le,Je]=d("hosts"),[Ae,ft]=d(null),[it,Dn]=d(""),[Gt,xn]=d(!1),Kt=te(!1),Vn=te(0),[ro,lo]=d([]),[Jt,Mn]=d(""),[$n,Rn]=d(!1),[co,uo]=d({}),[po,Fn]=d({}),[kt,Ct]=d(""),Xt=te(!1),Zt=te(null),Bn=Ne("network"),Hn=Ne("network_pipe"),On=Ne("host_interface"),Un=Ne("host_tap"),qn=Ne("interface"),Wn=Ne("acl"),zn=Ne("route_table"),tt=ze();z(()=>{let[i,D,j]=tt.segments;if(D!=="hosttaps"&&M(!1),D!=="pipes"&&x(!1),D!=="hosts"&&D!=="hosttaps"&&fe(null),i==="networks"){if(me(!1),_(null),X(null),le(null),ve(null),q(!1),Te(null),Ke(null),D==="new"){ee(null),ie(!0),Je("networks");return}if(D&&j==="edit"){let re=t.find(he=>String(he.id)===D);if(re){ee(re),ie(!0),Je("networks");return}}ie(!1),ee(null),D&&Ia.some(re=>re.id===D)?Je(D):Je("networks");return}if(ie(!1),ee(null),i==="interfaces"){if(Je("interfaces"),q(!1),Te(null),D==="new"){me(!0),_(null),X(null),ve(null),Ke(null);return}let re=b.find(he=>String(he.id)===D);me(!1),X(null),_(re&&j==="attach"?re:null),ve(re&&j==="acls"?re:null),Ke(re&&j==="route-tables"?re:null);return}if(i==="acls"){if(Je("acl"),me(!1),_(null),X(null),ve(null),Ke(null),q(!1),Te(null),D==="new"){le({acl:null});return}let re=u.find(he=>String(he.id)===D);le(re&&j==="edit"?{acl:re}:null);return}if(i==="route-tables"){if(Je("routes"),me(!1),_(null),X(null),le(null),ve(null),Ke(null),D==="new"){q(!0),Te(null);return}if(q(!1),j==="routes"||!j){let re=tt.query.get("ipv6")==="true",he=K.find(He=>String(He.id)===D&&He.ipv6===re);Te(he??null),he?tt.segments[3]==="new"?Oe({table:he}):tt.segments[3]&&tt.segments[4]==="edit"?Oe({table:he,routeID:Number(tt.segments[3])}):Oe(null):Oe(null)}else Te(null),Oe(null);return}me(!1),Oe(null),_(null),X(null),le(null),ve(null),q(!1),Te(null),Ke(null)},[tt.path,tt.search,b,u,t,K]);let Be=i=>{i.networks&&(n(i.networks),window.dispatchEvent(new CustomEvent("rookery:networks-changed",{detail:i.networks}))),i.interfaces&&(w(i.interfaces),window.dispatchEvent(new CustomEvent("rookery:interfaces-changed",{detail:i.interfaces}))),i.acls&&k(i.acls),i.vms&&A(i.vms),i.vnfs&&m(i.vnfs)},Gn=async(i=!0)=>{try{let D=await V("/api/networks"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"})),Be({networks:j}),i&&p("")}catch(D){i&&p(D instanceof Error?D.message:String(D))}},fo=async(i=!0)=>{try{let D=await V("/api/network-pipes"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.id-he.id),l(j),i&&H("")}catch(D){i&&H(D instanceof Error?D.message:String(D))}},jt=async(i=!0)=>{try{let D=await V("/api/host/interfaces"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"})),o(j),i&&F("")}catch(D){i&&F(D instanceof Error?D.message:String(D))}},mo=async(i=!0)=>{try{let D=await V("/api/host/taps"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"})),r(j),i&&Y("")}catch(D){i&&Y(D instanceof Error?D.message:String(D))}},ho=async i=>{if(window.confirm(`Delete host tap ${i.name}? The host will lose its connection to ${i.network_name}.`)){ae(i.name),Y("");try{await V(`/api/host/taps/${i.id}`,{method:"DELETE"}),r(D=>D.filter(j=>j.id!==i.id)),oe(`Deleted host tap ${i.name}`)}catch(D){let j=D instanceof Error?D.message:String(D);Y(`Could not delete ${i.name}: ${j}`),oe(`Could not delete host tap ${i.name}: ${j}`,!0)}finally{ae("")}}},bo=async i=>{if(window.confirm(`Bring ${i.name} into VPP? +`);return e("dialog",{ref:m,id:"interface-acl-dialog","aria-labelledby":"interface-acl-title",onClose:c,onClick:b=>{b.target===b.currentTarget&&y()},onCancel:b=>{k&&b.preventDefault()},children:e("form",{novalidate:!0,onSubmit:b=>{C(b)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface policy"}),e("h2",{id:"interface-acl-title",children:"Assign ACLs"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:y,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.name," \xB7 ",t.network_name," \xB7 ",t.mac]}),e("div",{class:"form-grid",children:[e(da,{label:"From interface",hint:"Applied to packets entering the interface, in this order. VPP evaluates the first matching rule across the list.",acls:n,selected:r,onChange:g}),e(da,{label:"To interface",hint:"Applied to packets leaving the interface, in this order.",acls:n,selected:w,onChange:d}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Full ACL preview"}),e("pre",{class:"acl-preview",children:A})]})]}),L?e("p",{class:"form-error",role:"alert",children:L}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:y,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:k||!s,title:s?"":"VPP must be ready to apply interface ACLs.",children:k?"Applying\u2026":"Apply ACLs"})]})]})})}var Li="[A-Za-z0-9][A-Za-z0-9._\\-]*";function pa({networks:t,vms:n,onClose:s,onSaved:c}){let[a,o]=l(""),[f,r]=l(t[0]?String(t[0].id):""),[g,w]=l(""),[d,k]=Pe("interface"),[h,L]=l(!1),[T,m]=l(""),y=ee(null),C=ee(null),E=ee(null);K(()=>{y.current?.showModal(),C.current?.focus()},[]);let P=()=>{h||y.current?.close()},A=async b=>{if(b.preventDefault(),!(h||!E.current?.reportValidity())){m(""),L(!0);try{let _=await M("/api/interfaces",{method:"POST",body:JSON.stringify({name:a.trim(),network_id:Number(f),attached_vm_id:g||null})});await Ee("interface",_.id,_.name,d),c(_)}catch(_){m(_ instanceof Error?_.message:String(_))}finally{L(!1)}}};return e("dialog",{ref:y,id:"interface-dialog","aria-labelledby":"interface-dialog-title",onClose:s,onClick:b=>{b.target===b.currentTarget&&P()},onCancel:b=>{h&&b.preventDefault()},children:e("form",{ref:E,novalidate:!0,onSubmit:b=>{A(b)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP vhost-user"}),e("h2",{id:"interface-dialog-title",children:"Add virtual interface"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:P,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:C,type:"text",required:!0,maxlength:64,pattern:Li,autocomplete:"off",spellcheck:!1,placeholder:"vm01-eth0",value:a,onInput:b=>o(b.currentTarget.value)}),e("span",{class:"field-hint",children:"Creates a vhost-user socket under the configured VPP directory."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Rookery Network"}),e("select",{required:!0,value:f,onChange:b=>r(b.currentTarget.value),children:t.map(b=>e("option",{value:String(b.id),children:[b.name," \xB7 bd ",b.bridge_domain_id]},b.id))})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Attach to VM ",e("em",{children:"optional"})]}),e("select",{value:g,onChange:b=>w(b.currentTarget.value),children:[e("option",{value:"",children:"Leave unattached"}),n.map(b=>{let _=b.status==="running"||b.status==="starting";return e("option",{value:b.id,children:_?`${b.name} (running \u2014 stop first)`:b.name},b.id)})]}),e("span",{class:"field-hint",children:"Stop the VM before attaching or detaching interfaces."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:d,onChange:k,disabled:h})})]}),T?e("p",{class:"form-error",role:"alert",children:T}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:P,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h,children:h?"Creating\u2026":"Add interface"})]})]})})}function fa({iface:t,tables:n,onClose:s,onSaved:c}){let[a,o]=l(String(t.ipv4_table_id??0)),[f,r]=l(String(t.ipv6_table_id??0)),[g,w]=l(!1),[d,k]=l(""),h=ee(null);K(()=>{h.current?.showModal()},[]);let L=()=>{g||h.current?.close()},T=async y=>{if(y.preventDefault(),!g){k(""),w(!0);try{let C=await M(`/api/interfaces/${t.id}/route-tables`,{method:"PUT",body:JSON.stringify({ipv4_table_id:Number(a),ipv6_table_id:Number(f)})});c(C)}catch(C){k(C instanceof Error?C.message:String(C))}finally{w(!1)}}},m=y=>n.filter(C=>C.ipv6===y&&C.table_id!==0);return e("dialog",{ref:h,id:"interface-route-tables-dialog","aria-labelledby":"interface-route-tables-title",onClose:s,onClick:y=>{y.target===y.currentTarget&&L()},onCancel:y=>{g&&y.preventDefault()},children:e("form",{novalidate:!0,onSubmit:y=>{T(y)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface FIB binding"}),e("h2",{id:"interface-route-tables-title",children:["Route tables \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:L,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name," \xB7 ",t.mac,". Table 0 is the default VRF."]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 FIB"}),e("select",{value:a,onChange:y=>o(y.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),m(!1).map(y=>e("option",{value:String(y.table_id),children:[y.name," (",y.table_id,")"]},`v4-${y.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 FIB"}),e("select",{value:f,onChange:y=>r(y.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),m(!0).map(y=>e("option",{value:String(y.table_id),children:[y.name," (",y.table_id,")"]},`v6-${y.id}`))]})]})]}),d?e("p",{class:"form-error",role:"alert",children:d}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:g,onClick:L,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:g,children:g?"Applying\u2026":"Apply route tables"})]})]})})}var Ii="[A-Za-z0-9][A-Za-z0-9._\\-]*";function ma({network:t,onClose:n,onSaved:s}){let c=!!t,[a,o]=l(t?.name||""),[f,r]=l(t?.ipv4_cidr||"10.0.0.0/16"),[g,w]=l(t?.ipv6_cidr||"fd00:20::/64"),[d,k]=Pe("network",t?.id),[h,L]=l(!1),[T,m]=l(""),y=ee(null),C=ee(null),E=ee(null);K(()=>{y.current?.showModal(),C.current?.focus()},[]);let P=()=>{h||y.current?.close()},A=async b=>{if(b.preventDefault(),!(h||!E.current?.reportValidity())){m(""),L(!0);try{let _=await M(c&&t?`/api/networks/${t.id}`:"/api/networks",{method:c?"PUT":"POST",body:JSON.stringify(c?{ipv4_cidr:f.trim(),ipv6_cidr:g.trim()}:{name:a.trim(),ipv4_cidr:f.trim(),ipv6_cidr:g.trim()})});await Ee("network",_.id,_.name,d),s(_)}catch(_){m(_ instanceof Error?_.message:String(_))}finally{L(!1)}}};return e("dialog",{ref:y,id:"network-dialog","aria-labelledby":"network-dialog-title",onClose:n,onClick:b=>{b.target===b.currentTarget&&P()},onCancel:b=>{h&&b.preventDefault()},children:e("form",{ref:E,novalidate:!0,onSubmit:b=>{A(b)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP bridge domain"}),e("h2",{id:"network-dialog-title",children:c?"Edit Rookery Network":"Create Rookery Network"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:P,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:C,type:"text",required:!0,maxlength:64,pattern:Ii,autocomplete:"off",spellcheck:!1,placeholder:"lab-net",value:a,disabled:c,onInput:b=>o(b.currentTarget.value)}),e("span",{class:"field-hint",children:c?"The network name is immutable.":"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 network CIDR"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"10.0.0.0/16",value:f,onInput:b=>r(b.currentTarget.value)}),e("span",{class:"field-hint",children:["Address range for this network; .1 is the BVI and .2 the VNF stack.",c?" CIDRs can only be changed while the network has no VNFs, host taps, or BVI.":""]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 network CIDR"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"fd00:20::/64",value:g,onInput:b=>w(b.currentTarget.value)}),e("span",{class:"field-hint",children:"IPv6 range reserved for this network."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:d,onChange:k,disabled:h})})]}),T?e("p",{class:"form-error",role:"alert",children:T}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:P,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h,children:h?"Creating\u2026":"Create network"})]})]})})}function ha({networks:t,onClose:n,onSaved:s}){let[c,a]=l(t[0]?String(t[0].id):""),[o,f]=l(t[1]?String(t[1].id):""),[r,g]=Pe("network_pipe"),[w,d]=l(!1),[k,h]=l(""),L=ee(null),T=ee(null);K(()=>{L.current?.showModal()},[]);let m=()=>{w||L.current?.close()},y=async C=>{if(C.preventDefault(),!(w||!T.current?.reportValidity())){if(!c||!o||c===o){h("Choose two different networks.");return}d(!0),h("");try{let E=await M("/api/network-pipes",{method:"POST",body:JSON.stringify({network_a_id:Number(c),network_b_id:Number(o)})});await Ee("network_pipe",E.id,`pipes for ${E.network_a_name} and ${E.network_b_name}`,r),s(E)}catch(E){h(E instanceof Error?E.message:String(E))}finally{d(!1)}}};return e("dialog",{ref:L,id:"network-pipe-dialog","aria-labelledby":"network-pipe-title",onClose:n,onClick:C=>{C.target===C.currentTarget&&m()},onCancel:C=>{w&&C.preventDefault()},children:e("form",{ref:T,novalidate:!0,onSubmit:C=>{y(C)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Bridge-domain pipe"}),e("h2",{id:"network-pipe-title",children:"Connect two networks"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:m,children:"\xD7"})]}),e("p",{class:"field-hint",children:"Creates a VPP pipe pair and places one end in each bridge domain. This bridges L2 traffic between the two Rookery Networks."}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network A"}),e("select",{required:!0,value:c,onChange:C=>a(C.currentTarget.value),children:t.map(C=>e("option",{value:String(C.id),children:C.name},C.id))})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network B"}),e("select",{required:!0,value:o,onChange:C=>f(C.currentTarget.value),children:t.map(C=>e("option",{value:String(C.id),children:C.name},C.id))})]}),e("div",{class:"field field-wide",children:e(Le,{labels:r,onChange:g,disabled:w})})]}),t.length<2?e("p",{class:"field-hint",children:"Create at least two networks before adding a pipe."}):null,k?e("p",{class:"form-error",role:"alert",children:k}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:w,onClick:m,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:w||t.length<2,children:w?"Creating\u2026":"Create pipe"})]})]})})}var Ni={interface:"any",direction:"both",maxMB:10,snapLength:2048};function Ht(t){return t===void 0||!Number.isFinite(t)||t<0?"\u2014":t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KiB`:t<1024*1024*1024?`${(t/(1024*1024)).toFixed(1)} MiB`:`${(t/(1024*1024*1024)).toFixed(2)} GiB`}function _n(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?t:n.toLocaleString()}function Di(t){switch(t){case"rx":return"RX only";case"tx":return"TX only";case"both":case"rx/tx":return"RX + TX";default:return t||"\u2014"}}function ba(t){return`/api/troubleshoot/capture/files/${encodeURIComponent(t)}`}function ga(){let[t,n]=l([]),[s,c]=l(null),[a,o]=l([]),[f,r]=l(Ni),[g,w]=l(!0),[d,k]=l(!1),[h,L]=l(!1),[T,m]=l(""),[y,C]=l(""),E=Math.min(1e6,Math.max(1,Math.ceil(f.maxMB*1024*1024/(f.snapLength+16)))),P=E*(f.snapLength+16),A=f.maxMB>=1&&f.maxMB<=100&&f.snapLength>=64&&f.snapLength<=9e3,b=!!s?.active,_=async()=>{let x=await M("/api/troubleshoot/capture/files"),F=Array.isArray(x)?x:[];o(F)},R=async()=>{w(!0),C("");let[x,F]=await Promise.allSettled([M("/api/troubleshoot/capture/interfaces"),M("/api/troubleshoot/capture")]);if(x.status==="fulfilled"?n(Array.isArray(x.value)?x.value:[]):C(x.reason instanceof Error?x.reason.message:String(x.reason)),F.status==="fulfilled"){let B=F.value;c(B.status??null),o(Array.isArray(B.files)?B.files:[])}else{y||C(F.reason instanceof Error?F.reason.message:String(F.reason));try{await _()}catch{}}w(!1)};K(()=>{R()},[]),K(()=>{if(!b)return;let x=!1,F=window.setInterval(async()=>{try{let B=await M("/api/troubleshoot/capture");if(x)return;let z=Array.isArray(B.files)?B.files:[];c(B.status??null),o(z),C("")}catch(B){x||C(B instanceof Error?B.message:String(B))}},1500);return()=>{x=!0,window.clearInterval(F)}},[b]);let H=async()=>{if(A){k(!0),C("");try{let x=await M("/api/troubleshoot/capture",{method:"POST",body:JSON.stringify({interface:f.interface,direction:f.direction,max_bytes:Math.round(f.maxMB*1024*1024),snap_length:f.snapLength})});c(x),le("Packet capture started")}catch(x){let F=x instanceof Error?x.message:String(x);C(F),le(F,!0)}finally{k(!1)}}},G=async x=>{if(!(b&&s?.file===x.name)&&window.confirm(`Delete ${x.name}?`)){m(x.name),C("");try{await M(ba(x.name),{method:"DELETE"}),o(F=>F.filter(B=>B.name!==x.name)),c(F=>F&&F.file===x.name?{...F,file:""}:F),le("Packet capture deleted")}catch(F){let B=F instanceof Error?F.message:String(F);C(B),le(B,!0)}finally{m("")}}},re=async()=>{L(!0),C("");try{let x=await M("/api/troubleshoot/capture/stop",{method:"POST"});c(x);try{await _()}catch{}le("Packet capture stopped")}catch(x){let F=x instanceof Error?x.message:String(x);C(F),le(F,!0)}finally{L(!1)}};return e("div",{class:"capture-panel",children:[e("div",{class:"capture-toolbar",children:[e("span",{class:b?"capture-badge active":"capture-badge",children:b?"Capturing":"Idle"}),e("button",{class:"button ghost",type:"button",disabled:g||b||d||h,onClick:()=>{R()},children:"Refresh"})]}),y?e("div",{class:"banner",role:"alert",children:y}):null,e("div",{class:"capture-form",children:[e("label",{children:[e("span",{children:"Interface"}),e("select",{value:f.interface,disabled:b,onChange:x=>r(F=>({...F,interface:x.currentTarget.value})),children:[e("option",{value:"any",children:"Any interface"}),t.map(x=>e("option",{value:x.name,children:[x.name," \xB7 #",x.sw_if_index,x.type?` \xB7 ${x.type}`:"",x.up?"":" \xB7 down"]},x.name))]})]}),e("label",{children:[e("span",{children:"Direction"}),e("select",{value:f.direction,disabled:b,onChange:x=>r(F=>({...F,direction:x.currentTarget.value})),children:[e("option",{value:"both",children:"RX + TX"}),e("option",{value:"rx",children:"RX only"}),e("option",{value:"tx",children:"TX only"})]})]}),e("label",{children:[e("span",{children:"Max size (MB)"}),e("input",{type:"number",min:1,max:100,step:1,value:f.maxMB,disabled:b,onChange:x=>r(F=>({...F,maxMB:Number(x.currentTarget.value)}))})]}),e("label",{children:[e("span",{children:"Snap length (bytes)"}),e("input",{type:"number",min:64,max:9e3,step:64,value:f.snapLength,disabled:b,onChange:x=>r(F=>({...F,snapLength:Number(x.currentTarget.value)}))})]})]}),e("p",{class:"field-hint",children:["Approx. ",Ht(P)," /"," ",E.toLocaleString()," packets. VPP keeps up to the computed packet count and writes the pcap under the host\u2019s ",e("code",{children:"/tmp"}),"; only traffic on the selected interface is captured."]}),e("div",{class:"capture-actions",children:[b?e("button",{class:"button primary",type:"button",disabled:h,onClick:()=>{re()},children:h?"Stopping\u2026":"Stop capture"}):e("button",{class:"button primary",type:"button",disabled:!A||d||g,onClick:()=>{H()},children:d?"Starting\u2026":"Start capture"}),s?.file?e("span",{class:"capture-current-file",children:[b?"Writing":"Last capture",": ",e("code",{children:s.file})]}):null]}),s?e("div",{class:b?"capture-status active":"capture-status",children:[e("div",{class:"capture-status-head",children:[e("strong",{children:b?"Capture running":"No capture running"}),s.interface?e("span",{children:s.interface}):null]}),e("div",{class:"capture-status-grid",children:[e("span",{children:[e("b",{children:"Direction"}),Di(s.direction)]}),e("span",{children:[e("b",{children:"Size"}),Ht(s.size)]}),e("span",{children:[e("b",{children:b?"Buffer":"Captured"}),b?`${et(s.buffered)} / ${et(s.max_packets)} packets`:`${et(s.packets)} packets`]}),e("span",{children:[e("b",{children:"Limit"}),et(s.max_packets)," packets \xB7 ",Ht(s.max_bytes)]}),e("span",{children:[e("b",{children:"Started"}),_n(s.started_at)]}),s.stopped_at?e("span",{children:[e("b",{children:"Stopped"}),_n(s.stopped_at)]}):null]}),s.raw?e("details",{class:"capture-raw",children:[e("summary",{children:"VPP output"}),e("pre",{children:s.raw})]}):null]}):null,e("div",{class:"capture-files",children:[e("div",{class:"capture-files-head",children:[e("h3",{children:"Captures"}),e("span",{children:[a.length," file",a.length===1?"":"s"]})]}),a.length===0?e("p",{class:"field-hint",children:"No captures yet. Start a capture, then stop it to inspect the pcap."}):e("div",{class:"table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{children:"File"}),e("th",{children:"Size"}),e("th",{children:"Modified"}),e("th",{children:"Actions"})]})}),e("tbody",{children:a.map(x=>e("tr",{children:[e("td",{children:e("code",{children:x.name})}),e("td",{children:Ht(x.size)}),e("td",{children:_n(x.modified)}),e("td",{children:e("div",{class:"capture-file-actions",children:[e("a",{class:"button ghost",href:ba(x.name),download:!0,children:"Download"}),e("button",{class:"button ghost danger",type:"button",disabled:T===x.name||b&&s?.file===x.name,title:b&&s?.file===x.name?"Stop the capture before deleting it":void 0,onClick:()=>{G(x)},children:T===x.name?"Deleting\u2026":"Delete"})]})})]},x.name))})]})})]})]})}var Ai=/^\s*Packet\s+(\d+)\s*$/,va=/^\s*\d{2}:\d{2}:\d{2}:\d+:\s+(\S+)/;function xi(t){let n=t.replace(/\r/g,"").split(` +`),s=[],c=null;for(let a of n){let o=a.match(Ai);if(o){c&&s.push({...c,raw:c.lines.join(` +`).trim()}),c={number:Number(o[1]),nodes:[],lines:[a]};continue}if(!c)continue;c.lines.push(a);let f=a.match(va);f&&c.nodes.push(f[1])}if(c&&s.push({...c,raw:c.lines.join(` +`).trim()}),s.length===0){let a=n.map(o=>o.match(va)?.[1]).filter(o=>!!o);a.length>0&&s.push({number:1,nodes:a,raw:t.trim()})}return s}function Mi({packet:t}){return t.nodes.length===0?e("p",{class:"empty-state",children:"No VPP node path found for this packet."}):e("div",{class:"trace-path-panel",children:[e("div",{class:"trace-path-head",children:[e("strong",{children:["Packet ",t.number]}),e("span",{class:"muted",children:[t.nodes.length," nodes"]})]}),e("ol",{class:"trace-path","aria-label":`Packet ${t.number} node path`,children:t.nodes.map((n,s)=>e("li",{children:[e("span",{class:"trace-path-node","data-drop":n.includes("drop")?"true":void 0,children:n}),s{E(!0);try{let B=await M("/api/troubleshoot/trace/nodes"),z=Array.isArray(B)?B:Array.isArray(B.nodes)?B.nodes:[];y(z),A("")}catch(B){A(B instanceof Error?B.message:String(B))}finally{E(!1)}};K(()=>{t&&R()},[t]),K(()=>{n||m.length===0||s(m[0])},[n,m]);let H=async B=>{if(B.preventDefault(),!(r||!n.trim())){T(""),g(!0),f(null);try{let z=await M("/api/troubleshoot/trace",{method:"POST",body:JSON.stringify({node:n.trim(),count:Number(c)})});f({...z,raw:""})}catch(z){T(z instanceof Error?z.message:String(z))}finally{g(!1)}}},G=async()=>{T(""),d(!0);try{let B=await M("/api/troubleshoot/trace");f(z=>({...B,node:z?.node??n,count:z?.count??Number(c)}))}catch(B){T(B instanceof Error?B.message:String(B))}finally{d(!1)}},re=Ke(()=>xi(o?.raw??""),[o?.raw]);K(()=>{_(0)},[o?.raw]);let x=re[b],F=async()=>{T(""),h(!0);try{await M("/api/troubleshoot/trace",{method:"DELETE"}),f(null)}catch(B){T(B instanceof Error?B.message:String(B))}finally{h(!1)}};return e("div",{class:"trace-panel",children:[e("form",{class:"trace-form",novalidate:!0,onSubmit:B=>{H(B)},children:[e("div",{class:"field trace-node-field",children:[e("span",{class:"field-label",children:"VPP graph node"}),e("select",{class:"trace-node-list",required:!0,value:n,onChange:B=>s(B.currentTarget.value),children:[e("option",{value:"",disabled:!0,children:"Select a VPP node\u2026"}),m.map(B=>e("option",{value:B,children:B},B))]}),e("span",{class:"field-hint",children:m.length>0?`${m.length} VPP node${m.length===1?"":"s"} available.`:P||"No VPP nodes loaded yet."}),e("button",{class:"row-action trace-refresh-nodes",type:"button",disabled:C,onClick:()=>{R()},children:C?"Loading nodes\u2026":"Refresh nodes"})]}),e("label",{class:"field trace-count-field",children:[e("span",{class:"field-label",children:"Packet count"}),e("input",{type:"number",required:!0,min:1,max:1e3,value:c,onInput:B=>a(B.currentTarget.value)}),e("span",{class:"field-hint",children:"Trace up to 1,000 packets."})]}),e("button",{class:"button primary",type:"submit",disabled:r||!n.trim()||Number(c)<1||Number(c)>1e3,children:r?"Starting\u2026":"Start trace"}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:()=>{G()},children:w?"Loading\u2026":"Show trace"}),e("button",{class:"button ghost",type:"button",disabled:k,onClick:()=>{F()},children:k?"Clearing\u2026":"Clear"})]}),e("p",{class:"field-hint",children:["Start a trace, generate traffic through the selected node, then click ",e("strong",{children:"Show trace"}),"."]}),L?e("div",{class:"banner",role:"alert",children:L}):null,o?e("div",{class:"trace-result",children:[e("div",{class:"trace-result-head",children:[e("span",{children:o.node?`${o.node} \xB7 ${o.count||0} packets`:"VPP trace"}),o.captured_at?e("span",{class:"muted",children:new Date(o.captured_at).toLocaleString()}):null]}),re.length>0?e("div",{class:"trace-visual",children:[e("label",{class:"field trace-packet-field",children:[e("span",{class:"field-label",children:"Packet"}),e("select",{value:String(b),onChange:B=>_(Number(B.currentTarget.value)),children:re.map((B,z)=>e("option",{value:String(z),children:["Packet ",B.number," \xB7 ",B.nodes.length," nodes"]},`${B.number}-${z}`))}),e("span",{class:"field-hint",children:"Select a packet to draw its VPP node path."})]}),x?e(Mi,{packet:x}):null]}):e("p",{class:"empty-state",children:"No trace packets captured yet. Generate traffic and click Show trace."}),e("details",{class:"trace-raw",children:[e("summary",{children:"Raw trace text"}),e("pre",{class:"trace-output",children:o.raw||"No trace packets captured yet. Generate traffic and click Show trace."})]})]}):e("p",{class:"empty-state",children:"No trace loaded."})]})}function _a({table:t,routeID:n,interfaces:s,bviInterfaces:c,hostInterfaces:a,hostTaps:o,tables:f,onBack:r,onSaved:g}){let w=n!==void 0,[d,k]=l(w),[h,L]=l(!1),[T,m]=l(""),[y,C]=l(t.ipv6?"2001:db8::/64":"10.0.0.0/24"),[E,P]=l(""),[A,b]=l(t.ipv6?"2001:db8::1":"10.0.0.1"),[_,R]=l(""),[H,G]=l(""),[re,x]=l(""),[F,B]=l(""),[z,J]=l(""),[se,ge]=l(""),[p,N]=l(""),[X,ae]=l(""),[$,S]=l(""),[v,V]=l(""),[ie,pe]=l(!1),[fe,ne]=l(!1),[me,ue]=l(""),[u,I]=l(""),[U,Q]=l(""),[Z,q]=l(!1),[_e,oe]=Pe("route",n);K(()=>{document.title=`${w?"Edit":"Add"} route \xB7 ${t.name} \xB7 rookery`},[w,t.name]),K(()=>{if(n===void 0)return;let j=!1;return k(!0),M(`/api/route-tables/${t.id}/routes?ipv6=${t.ipv6}`).then($e=>{if(j)return;let O=(Array.isArray($e)?$e:[]).find(we=>we.id===n);if(!O){m("Route not found.");return}C(O.prefix),P(O.description||""),b(O.next_hop||""),O.interface_id?(R(String(O.interface_id)),G("")):(R(""),G(O.egress_interface||"")),x(O.preference===void 0?"":String(O.preference)),B(O.count===void 0?"":String(O.count)),J(O.next_hop_table===void 0?"":String(O.next_hop_table)),ge(O.weight===void 0?"":String(O.weight)),N(O.udp_encap===void 0?"":String(O.udp_encap)),ae(O.ipv4_lookup_table===void 0?"":String(O.ipv4_lookup_table)),S(O.ipv6_lookup_table===void 0?"":String(O.ipv6_lookup_table)),V(O.mpls_lookup_table===void 0?"":String(O.mpls_lookup_table)),pe(!!O.resolve_via_host),ne(!!O.resolve_via_connected),ue(O.rx_ipv4_interface||""),I(O.rx_ipv6_interface||""),Q((O.out_labels||[]).join(" ")),q(!!O.drop),m("")}).catch($e=>m($e instanceof Error?$e.message:String($e))).finally(()=>{j||k(!1)}),()=>{j=!0}},[n,t.id,t.ipv6]);let he=async j=>{if(j.preventDefault(),h||d)return;if(!y.trim()){m("Enter a destination prefix.");return}if(me.trim()&&u.trim()){m("rx-ip4 and rx-ip6 are mutually exclusive.");return}if(!(A.trim()||_||H.trim()||Z||me.trim()||u.trim()||z||p||X||$||v)){m("Enter a next hop, egress interface, rx interface, lookup option, or drop.");return}let O=_.startsWith("vpp:")?_.slice(4):"",we=H.trim()||O,Te=_&&!O&&!H.trim()?Number(_):0,W=U.split(/[\s,]+/).map(xe=>xe.trim()).filter(Boolean).map(xe=>Math.trunc(Number(xe))).filter(xe=>Number.isFinite(xe)&&xe>=0),ke=xe=>{let ft=xe.trim();if(!ft)return;let rt=Math.max(0,Math.trunc(Number(ft)));return Number.isFinite(rt)?rt:void 0},Se=ke(re),De=ke(F),He=ke(se),Xe=ke(p),ce=ke(v),Re=z.trim(),Ae=X.trim(),Ie=$.trim(),Je={ipv6:t.ipv6,prefix:y.trim(),description:E.trim(),next_hop:A.trim(),...Te?{interface_id:Te}:{},...we?{egress_interface:we}:{},...Se!==void 0?{preference:Se}:{},...De!==void 0?{count:De}:{},...Re?{next_hop_table:Re}:{},...He!==void 0?{weight:He}:{},...Xe!==void 0?{udp_encap:Xe}:{},...Ae?{ipv4_lookup_table:Ae}:{},...Ie?{ipv6_lookup_table:Ie}:{},...ce!==void 0?{mpls_lookup_table:ce}:{},resolve_via_host:ie,resolve_via_connected:fe,rx_ipv4_interface:me.trim(),rx_ipv6_interface:u.trim(),out_labels:W,drop:Z};L(!0),m("");try{let xe;if(n===void 0)xe=await M(`/api/route-tables/${t.id}/routes`,{method:"POST",body:JSON.stringify(Je)});else{let{ipv6:ft,...rt}=Je;xe=await M(`/api/routes/${n}`,{method:"PUT",body:JSON.stringify({id:n,...rt})})}await Ee("route",xe.id,xe.prefix,_e),g()}catch(xe){m(xe instanceof Error?xe.message:String(xe))}finally{L(!1)}},ve=j=>f.filter($e=>$e.ipv6===j&&$e.table_id!==0);return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",onClick:r,children:["\u2190 Routes \xB7 ",t.name]}),e("p",{class:"eyebrow",children:[t.ipv6?"IPv6":"IPv4"," FIB \xB7 table ",t.table_id]}),e("h2",{children:w?"Edit route":"Add route"}),e("p",{class:"panel-subtitle",children:["Route options map directly to the VPP ",e("code",{children:"ip route"})," command."]})]})}),e("form",{class:"route-page-form",onSubmit:j=>{he(j)},children:[d?e("p",{class:"field-hint",children:"Loading route\u2026"}):null,e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Prefix"}),e("input",{type:"text",required:!0,value:y,onInput:j=>C(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Next hop"}),e("input",{type:"text",value:A,onInput:j=>b(j.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Egress interface"}),e("select",{value:_,onChange:j=>R(j.currentTarget.value),children:[e("option",{value:"",children:"Resolve via table"}),s.map(j=>e("option",{value:String(j.id),children:j.name},j.id)),c.length>0?e("optgroup",{label:"Network gateways (BVI)",children:c.map(j=>e("option",{value:`vpp:${j}`,children:j},`bvi-${j}`))}):null,a.some(j=>j.in_vpp&&j.vpp_name)?e("optgroup",{label:"Host interfaces",children:a.filter(j=>j.in_vpp&&j.vpp_name).map(j=>e("option",{value:`vpp:${j.vpp_name}`,children:[j.vpp_name," (host)"]},`host-${j.name}`))}):null,o.length>0?e("optgroup",{label:"Host taps",children:o.map(j=>e("option",{value:`vpp:${j.name}`,children:j.name},`tap-${j.id}`))}):null]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Other VPP interface"}),e("input",{type:"text",placeholder:"host-eno4",value:H,onInput:j=>G(j.currentTarget.value)}),e("span",{class:"field-hint",children:"Overrides the picker when set."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Metric / preference"}),e("input",{type:"number",min:"0",max:"255",value:re,onInput:j=>x(j.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Description"}),e("textarea",{rows:2,maxlength:1e3,value:E,onInput:j=>P(j.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Le,{labels:_e,onChange:oe,disabled:h})})]}),e("section",{class:"route-advanced","aria-labelledby":"route-advanced-title",children:[e("h4",{class:"route-advanced-title",id:"route-advanced-title",children:"Advanced VPP route options"}),e("p",{class:"field-hint",children:["Leave an option blank to omit it from the VPP command. Next-hop and IP lookup tables use route-table names; choose ",e("code",{children:"default"}),"for table ",e("code",{children:"0"}),". MPLS lookup uses a numeric table ID."]}),e("div",{class:"route-advanced-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Count"}),e("input",{type:"number",min:"1",max:"100000",placeholder:"1",value:F,onInput:j=>B(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Next-hop table"}),e("select",{value:z,onChange:j=>J(j.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(t.ipv6).map(j=>e("option",{value:j.name,children:[j.name," (",j.table_id,")"]},`nh-${j.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Weight"}),e("input",{type:"number",min:"0",max:"255",value:se,onInput:j=>ge(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"UDP encap"}),e("input",{type:"number",min:"0",value:p,onInput:j=>N(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 lookup table"}),e("select",{value:X,onChange:j=>ae(j.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(!1).map(j=>e("option",{value:j.name,children:[j.name," (",j.table_id,")"]},`v4-${j.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 lookup table"}),e("select",{value:$,onChange:j=>S(j.currentTarget.value),children:[e("option",{value:"",children:"Omit"}),e("option",{value:"default",children:"default (0)"}),ve(!0).map(j=>e("option",{value:j.name,children:[j.name," (",j.table_id,")"]},`v6-${j.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"MPLS lookup table ID"}),e("input",{type:"number",min:"0",value:v,onInput:j=>V(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"rx-ip4 interface"}),e("input",{type:"text",value:me,onInput:j=>ue(j.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"rx-ip6 interface"}),e("input",{type:"text",value:u,onInput:j=>I(j.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Out labels"}),e("input",{type:"text",placeholder:"100 200 300",value:U,onInput:j=>Q(j.currentTarget.value)})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:ie,onChange:j=>pe(j.currentTarget.checked)}),e("span",{children:"resolve-via-host"})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:fe,onChange:j=>ne(j.currentTarget.checked)}),e("span",{children:"resolve-via-connected"})]}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:Z,onChange:j=>q(j.currentTarget.checked)}),e("span",{children:"drop"})]})]})]}),T?e("p",{class:"form-error",role:"alert",children:T}):null,e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:r,disabled:h,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h||d,children:h?"Saving\u2026":w?"Save route":"Add route"})]})]})]})}function wa({tables:t,onCancel:n,onSaved:s}){let c=Math.max(0,...t.filter(C=>!C.is_default).map(C=>C.table_id))+1,[a,o]=l(""),[f,r]=l(String(c)),[g,w]=l(!1),[d,k]=Pe("route_table"),[h,L]=l(!1),[T,m]=l("");K(()=>{document.title="Create route table \xB7 rookery"},[]);let y=async C=>{if(C.preventDefault(),!h){m(""),L(!0);try{let E=await M("/api/route-tables",{method:"POST",body:JSON.stringify({name:a.trim(),table_id:Number(f),ipv6:g})});await Ee("route_table",E.id,E.name,d),s(E)}catch(E){m(E instanceof Error?E.message:String(E))}finally{L(!1)}}};return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",onClick:n,children:"\u2190 Route tables"}),e("p",{class:"eyebrow",children:"IP FIB / VRF"}),e("h2",{children:"Create route table"}),e("p",{class:"panel-subtitle",children:"Define an IPv4 or IPv6 VRF/FIB table. Table 0 is the built-in default VRF."})]})}),e("form",{class:"route-page-form",novalidate:!0,onSubmit:C=>{y(C)},children:[e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{type:"text",required:!0,maxLength:63,placeholder:"tenant-blue",value:a,onInput:C=>o(C.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Table ID"}),e("input",{type:"number",required:!0,min:1,value:f,onInput:C=>r(C.currentTarget.value)}),e("span",{class:"field-hint",children:"Custom tables start at 1; table 0 is the default VRF."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:g,onChange:C=>w(C.currentTarget.checked)}),e("span",{children:"IPv6 FIB table"})]}),e("span",{class:"field-hint",children:"The same table ID can be used separately for IPv4 and IPv6."})]}),e("div",{class:"field field-wide",children:e(Le,{labels:d,onChange:k,disabled:h})})]}),T?e("p",{class:"form-error",role:"alert",children:T}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:h,onClick:n,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:h||!a.trim()||Number(f)<1,children:h?"Creating\u2026":"Create route table"})]})]})]})}function Vi(t){let n=[];return(t.count||0)>1&&n.push(`count ${t.count}`),t.next_hop_table&&n.push(`next-hop-table ${t.next_hop_table}`),t.weight&&n.push(`weight ${t.weight}`),t.udp_encap&&n.push(`udp-encap ${t.udp_encap}`),t.ipv4_lookup_table&&n.push(`ip4-lookup-in-table ${t.ipv4_lookup_table}`),t.ipv6_lookup_table&&n.push(`ip6-lookup-in-table ${t.ipv6_lookup_table}`),t.mpls_lookup_table&&n.push(`mpls-lookup-in-table ${t.mpls_lookup_table}`),t.resolve_via_host&&n.push("resolve-via-host"),t.resolve_via_connected&&n.push("resolve-via-connected"),t.rx_ipv4_interface&&n.push(`rx-ip4 ${t.rx_ipv4_interface}`),t.rx_ipv6_interface&&n.push(`rx-ip6 ${t.rx_ipv6_interface}`),t.out_labels&&t.out_labels.length>0&&n.push(`out-labels ${t.out_labels.join(" ")}`),t.drop&&n.push("drop"),n.join(" \xB7 ")}function ka({table:t,onBack:n,onChanged:s}){let[c,a]=l([]),[o,f]=l([]),[r,g]=l(!0),[w,d]=l(!0),[k,h]=l(""),[L,T]=l(""),m=Me("route"),y=`/route-tables/${t.id}/routes/new?ipv6=${t.ipv6}`,C=async()=>{g(!0);try{let b=await M(`/api/route-tables/${t.id}/routes?ipv6=${t.ipv6}`);a(Array.isArray(b)?b:[]),h("")}catch(b){h(b instanceof Error?b.message:String(b))}finally{g(!1)}},E=async()=>{d(!0);try{let b=await M(`/api/route-tables/${t.id}/fib?ipv6=${t.ipv6}`);f(Array.isArray(b)?b:[]),T("")}catch(b){T(b instanceof Error?b.message:String(b))}finally{d(!1)}};K(()=>{C()},[t.id,t.ipv6]),K(()=>{E()},[t.id,t.ipv6]),K(()=>{document.title=`Routes \xB7 ${t.name} \xB7 rookery`},[t.name]);let P=async b=>{if(window.confirm(`Delete route ${b.prefix}${b.next_hop?` via ${b.next_hop}`:""}?`)){h("");try{await M(`/api/routes/${b.id}`,{method:"DELETE"}),a(_=>_.filter(R=>R.id!==b.id)),await E(),s()}catch(_){h(_ instanceof Error?_.message:String(_))}}},A=o.filter(b=>b.dynamic);return e("div",{class:"route-page",children:[e("header",{class:"route-page-head",children:[e("div",{children:[e("button",{class:"back-link",type:"button",onClick:n,children:"\u2190 Route tables"}),e("p",{class:"eyebrow",children:[t.ipv6?"IPv6":"IPv4"," FIB \xB7 table ",t.table_id,t.is_default?" \xB7 default VRF":""]}),e("h2",{children:["Routes \xB7 ",t.name]}),e("p",{class:"panel-subtitle",children:"Static routes are managed by Rookery. Dynamic routes are read directly from the live VPP FIB."})]}),e("button",{class:"button primary",type:"button",onClick:()=>te(y),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add route"]})]}),e("section",{class:"route-page-section","aria-labelledby":"static-routes-title",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h3",{id:"static-routes-title",children:"Static routes"}),e("p",{class:"field-hint",children:"Prefixes installed in this VRF by Rookery."})]})}),k?e("p",{class:"form-error",role:"alert",children:k}):null,e("div",{class:"table-wrap route-table-wrap",children:r?e("p",{class:"empty-state",children:"Loading routes\u2026"}):c.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Next hop"}),e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Options"}),e("th",{scope:"col",children:"Preference"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:c.map(b=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:b.prefix}),b.description?e("span",{class:"vm-description",children:b.description}):null,e(Ve,{entityType:"route",entityID:b.id,name:b.prefix,labels:m.labels[String(b.id)],onSaved:_=>m.setEntityLabels(String(b.id),_)})]}),e("td",{class:"mono",children:b.drop?"drop":b.next_hop||"attached"}),e("td",{class:"mono",children:b.egress_interface||b.interface_name||"resolved by table"}),e("td",{class:"mono",children:Vi(b)||"\u2014"}),e("td",{class:"mono",children:b.preference||0}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>te(`/route-tables/${t.id}/routes/${b.id}/edit?ipv6=${t.ipv6}`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{P(b)},children:"Delete"})]})})]},b.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2197"}),e("h3",{children:"No static routes"}),e("p",{children:"Add a route to this VRF."}),e("button",{class:"button primary",type:"button",onClick:()=>te(y),children:"Add route"})]})})]}),e("section",{class:"fib-section route-page-section","aria-labelledby":"fib-routes-title",children:[e("div",{class:"panel-head fib-head",children:[e("div",{children:[e("h3",{id:"fib-routes-title",children:"Dynamic routes"}),e("p",{class:"field-hint",children:"Read-only entries from the live VPP FIB, including connected, DHCP, ND, and adjacency routes."})]}),e("button",{class:"button ghost",type:"button",disabled:w,onClick:()=>{E()},children:w?"Refreshing\u2026":"Refresh FIB"})]}),L?e("p",{class:"form-error",role:"alert",children:L}):w?e("p",{class:"empty-state",children:"Loading VPP FIB\u2026"}):A.length>0?e("div",{class:"table-wrap route-table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Source"}),e("th",{scope:"col",children:"Paths"})]})}),e("tbody",{children:A.map(b=>e("tr",{children:[e("td",{class:"mono",children:b.prefix}),e("td",{children:e("span",{class:"fib-source",children:b.source_name})}),e("td",{children:(b.paths??[]).length>0?(b.paths??[]).map((_,R)=>e("div",{class:"fib-path",children:[e("span",{class:"fib-path-type",children:_.type})," ",e("span",{class:"mono",children:_.next_hop||"\u2014"}),_.interface_name?e("span",{children:[" via ",_.interface_name]}):null]},`${b.prefix}-${R}`)):e("span",{class:"muted",children:"no paths"})})]},`${b.ipv6?"v6":"v4"}-${b.prefix}`))})]})}):e("p",{class:"empty-state",children:"No dynamic routes in this table."})]})]})}function Ca({name:t,label:n,hostAddresses:s,onClose:c}){let[a,o]=l(""),[f,r]=l(!1),[g,w]=l([]),[d,k]=l(!1),[h,L]=l(""),[T,m]=l(""),[y,C]=l(!0),[E,P]=l(!1),[A,b]=l(!1),[_,R]=l(""),H=ee(null),G=ee(null),re=`/api/vpp/interfaces/${encodeURIComponent(t)}/addresses`,x=se=>{w(se.live_addresses||[]),k(!!se.dhcp_client_configured),L(se.dhcp_client_status||""),m(se.status_detail||"")};K(()=>{H.current?.showModal(),F()},[]);let F=async()=>{C(!0);try{let se=await M(re);o((se.addresses||[]).join(` +`)),r(!!se.dhcp_client_enabled),x(se),R("")}catch(se){R(se instanceof Error?se.message:String(se))}finally{C(!1),G.current?.focus()}},B=async()=>{if(!E){P(!0);try{let se=await M(re);x(se),R("")}catch(se){R(se instanceof Error?se.message:String(se))}finally{P(!1)}}},z=()=>{A||H.current?.close()},J=async se=>{if(se.preventDefault(),A||y)return;b(!0),R("");let ge=a.split(/\r?\n/).map(p=>p.trim()).filter(Boolean);try{let p=await M(re,{method:"PUT",body:JSON.stringify({addresses:ge,dhcp_client_enabled:f})});o((p.addresses||[]).join(` +`)),r(!!p.dhcp_client_enabled),x(p),le(`Saved configuration for ${t}`)}catch(p){R(p instanceof Error?p.message:String(p))}finally{b(!1)}};return e("dialog",{ref:H,id:"vpp-addresses-dialog","aria-labelledby":"vpp-addresses-title",onClose:c,onClick:se=>{se.target===se.currentTarget&&z()},onCancel:se=>{A&&se.preventDefault()},children:e("form",{onSubmit:se=>{J(se)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"VPP interface addresses"}),e("h2",{id:"vpp-addresses-title",children:["Addresses \xB7 ",t]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:z,children:"\xD7"})]}),e("div",{class:"startup-script-status",children:[e("span",{class:"status-badge","data-status":"running",children:n||"VPP interface"}),e("span",{class:"field-hint",children:"IPv4 and IPv6 CIDRs are applied directly to this VPP interface."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:f,onChange:se=>r(se.currentTarget.checked)}),e("span",{children:"Enable DHCP client (IPv4)"})]}),e("span",{class:"field-hint",children:"VPP requests an IPv4 lease from the network. This can be combined with static addresses."})]}),s?e("div",{class:"vpp-address-live field-wide",children:[e("div",{class:"vpp-address-live-head",children:e("span",{class:"field-label",children:"Host side addresses"})}),s.length>0?e("div",{class:"vpp-live-addresses mono",children:s.map(se=>e("span",{children:se},se))}):e("p",{class:"field-hint",children:"No host-side address assigned."})]}):null,e("div",{class:"vpp-address-live field-wide",children:[e("div",{class:"vpp-address-live-head",children:[e("span",{class:"field-label",children:"VPP side addresses"}),e("div",{class:"actions",children:[e("span",{class:"status-badge","data-status":d?"running":"disabled",children:["DHCP client ",d?"configured":"not configured"]}),e("button",{class:"row-action",type:"button",disabled:E||A,onClick:()=>{B()},children:E?"Refreshing\u2026":"Refresh status"})]})]}),T?e("p",{class:"field-hint",children:T}):g.length>0?e("div",{class:"vpp-live-addresses mono",children:g.map(se=>e("span",{children:se},se))}):e("p",{class:"field-hint",children:["No live address assigned yet",f?"; waiting for a DHCP lease.":"."]}),h?e("details",{class:"startup-script-output",children:[e("summary",{children:"DHCP client status"}),e("pre",{children:h})]}):null]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Addresses"}),e("textarea",{ref:G,class:"startup-script-editor",rows:8,spellcheck:!1,placeholder:`10.20.0.10/24 +fd00:20::10/64`,value:a,onInput:se=>o(se.currentTarget.value)}),e("span",{class:"field-hint",children:"One address per line, using CIDR notation. Leave empty to remove all Rookery-managed addresses. Existing addresses managed by other Rookery features are not shown here."})]}),_?e("p",{class:"form-error",role:"alert",children:_}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:A,onClick:z,children:"Close"}),e("button",{class:"button primary",type:"submit",disabled:A||y,children:A?"Saving\u2026":"Save addresses"})]})]})})}function Sa({name:t,label:n,tables:s,onClose:c,onSaved:a}){let[o,f]=l("0"),[r,g]=l("0"),[w,d]=l(!0),[k,h]=l(!1),[L,T]=l(""),m=ee(null),y=`/api/vpp/interfaces/${encodeURIComponent(t)}/route-tables`;K(()=>{m.current?.showModal(),C()},[]);let C=async()=>{d(!0);try{let b=await M(y);f(String(b.ipv4_table_id??0)),g(String(b.ipv6_table_id??0)),T("")}catch(b){T(b instanceof Error?b.message:String(b))}finally{d(!1)}},E=()=>{k||m.current?.close()},P=async b=>{if(b.preventDefault(),!(w||k)){h(!0),T("");try{let _=await M(y,{method:"PUT",body:JSON.stringify({ipv4_table_id:Number(o),ipv6_table_id:Number(r)})});a(_)}catch(_){T(_ instanceof Error?_.message:String(_))}finally{h(!1)}}},A=b=>s.filter(_=>_.ipv6===b&&_.table_id!==0);return e("dialog",{ref:m,id:"vpp-interface-route-tables-dialog","aria-labelledby":"vpp-interface-route-tables-title",onClose:c,onClick:b=>{b.target===b.currentTarget&&E()},onCancel:b=>{k&&b.preventDefault()},children:e("form",{novalidate:!0,onSubmit:b=>{P(b)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Interface FIB binding"}),e("h2",{id:"vpp-interface-route-tables-title",children:["Route tables \xB7 ",t]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:E,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[n,". Table 0 is the default VRF. Saving an explicit binding, including default (0), overrides any automatic network VRF for a host tap and may affect reachability."]}),e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv4 FIB"}),e("select",{value:o,disabled:w||k,onChange:b=>f(b.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),A(!1).map(b=>e("option",{value:String(b.table_id),children:[b.name," (",b.table_id,")"]},`v4-${b.id}`))]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"IPv6 FIB"}),e("select",{value:r,disabled:w||k,onChange:b=>g(b.currentTarget.value),children:[e("option",{value:"0",children:"default (0)"}),A(!0).map(b=>e("option",{value:String(b.table_id),children:[b.name," (",b.table_id,")"]},`v6-${b.id}`))]})]})]}),L?e("p",{class:"form-error",role:"alert",children:L}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:k,onClick:E,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:w||k,children:k?"Applying\u2026":"Apply route tables"})]})]})})}var kn=["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#b10026"],Ta=92,Pa=104,yt=60,Ea=900,$i=220,Ri=/^(?:vhost-user-input|tap-input|af-packet-input|interface-output|interface-\d+-(?:output|tx)(?:-deleted)?|(?:bvi|host|vm|vnf)[^ ]*-(?:output|tx)(?:-deleted)?)$/;function Fi(t){let n=/^(.*?)-(?:output|tx)(?:-deleted)?$/.exec(t);return n?n[1]:t}function Bi(t){return t==="vhost-user-input"?"vhost-user input":t==="tap-input"?"tap input":t==="af-packet-input"?"af-packet input":t==="interface"?"interface (generic)":/^interface-\d+$/.test(t)?`${t} (deleted)`:t.startsWith("vm-")?`${t} (VM interface)`:/^vnf/.test(t)?`${t} (VNF tap)`:/^bvi/.test(t)?`${t} (BVI)`:t.startsWith("host-")?`${t} (host NIC)`:t}function Hi(t){let n=new Map;for(let s of t.nodes){if(!s.isInterface)continue;let c=Fi(s.id),a=n.get(c);a?a.push(s.id):n.set(c,[s.id])}return Array.from(n.entries()).map(([s,c])=>({id:s,label:Bi(s),nodeIDs:c})).sort((s,c)=>s.label.localeCompare(c.label))}function Oi(t){let n=[],s=(c,a,o)=>{let f=t.nodes.filter(r=>o(r.id)).map(r=>r.id);f.length>0&&n.push({id:c,label:a,nodeIDs:f})};return s("feature:acl","ACL plugin",c=>c.startsWith("acl-plugin")),s("feature:nat","NAT44",c=>c.startsWith("nat")),s("feature:dhcp","DHCP",c=>c.includes("dhcp")),n}function La(t){let n=new Map;for(let s of t.nodes)n.set(s.id,new Set([s.id]));for(let s of t.edges)n.get(s.source)?.add(s.target),n.get(s.target)?.add(s.source);return n}function Ia(t){let n=Array.isArray(t.nodes)?t.nodes:[],s=Array.isArray(t.edges)?t.edges:[],c={nodes:[],edges:[],width:Ea,height:420,maxRank:0,maxLayerCount:0,backwardCount:0,interfaceCount:0};if(n.length===0)return c;let a=new Map;n.forEach((v,V)=>a.set(v.id,V));let o=n.length,f=Array.from({length:o},()=>[]),r=Array.from({length:o},()=>[]),g=[],w=new Set;for(let v of s){let V=a.get(v.source),ie=a.get(v.target);if(V===void 0||ie===void 0)continue;let pe=`${V}\0${ie}`;w.has(pe)||(w.add(pe),g.push({sourceIndex:V,targetIndex:ie,source:v.source,target:v.target}),V!==ie&&(f[V].push(ie),r[ie].push(V)))}let d=new Set;for(let v=0;v0&&f[v].every(V=>d.has(V))&&k.add(v);let h=[];for(let v=0;vL.has(V))),m.set(v,r[v].filter(V=>L.has(V)));let y=new Map,C=new Set,E=(v,V)=>{let ie=[v];C.add(v),y.set(v,V);for(let pe=0;pe(m.get(v)?.length??0)===0);P.length===0&&h.length>0&&P.push(h[0]);for(let v of P)C.has(v)||E(v,0);for(let v of h)C.has(v)||E(v,0);let A=0;for(let v of y.values())A=Math.max(A,v);let b=A+1,_=A+(k.size>0?2:1),R=new Array(o).fill(0);for(let v of h)R[v]=y.get(v)??0;for(let v of k)R[v]=b;for(let v of d)R[v]=_;let H=0;for(let v of R)H=Math.max(H,v);let G=Array.from({length:H+1},()=>[]);for(let v=0;vn[v].label||n[v].id;G.forEach(v=>{v.sort((V,ie)=>re(V).localeCompare(re(ie)))});let x=new Array(o).fill(0),F=()=>{G.forEach(v=>{v.forEach((V,ie)=>{x[V]=ie})})};F();let B=v=>{let V=G[R[v]];return V.length<=1?.5:x[v]/(V.length-1)};for(let v=0;v<5;v+=1){for(let V=1;V<=H;V+=1){let ie=G[V],pe=new Map;for(let fe of ie){let ne=r[fe].filter(me=>R[me]0?ne.reduce((me,ue)=>me+B(ue),0)/ne.length:B(fe))}ie.sort((fe,ne)=>(pe.get(fe)??0)-(pe.get(ne)??0)||re(fe).localeCompare(re(ne))),F()}for(let V=H-1;V>=0;V-=1){let ie=G[V],pe=new Map;for(let fe of ie){let ne=f[fe].filter(me=>R[me]>V);pe.set(fe,ne.length>0?ne.reduce((me,ue)=>me+B(ue),0)/ne.length:B(fe))}ie.sort((fe,ne)=>(pe.get(fe)??0)-(pe.get(ne)??0)||re(fe).localeCompare(re(ne))),F()}}let z=Math.max(...G.map(v=>v.length),1),J=Math.max(Ea,(z-1)*Ta+yt*2),se=Math.max(420,H*Pa+yt*2),ge=new Array(o).fill(J/2),p=new Array(o).fill(yt);G.forEach((v,V)=>{let ie=Math.min(J-yt*2,Math.max(0,(v.length-1)*Ta)),pe=J/2-ie/2;v.forEach((fe,ne)=>{ge[fe]=v.length===1?J/2:pe+ie*ne/(v.length-1),p[fe]=yt+V*Pa})});let N=n.map(v=>Number(v.width)||0),X=Math.min(...N),ae=Math.max(...N),$=n.map((v,V)=>{let ie=Number(v.fill)||0,pe=ae>X?(N[V]-X)/(ae-X):.5;return{id:v.id,label:v.label||v.id,x:ge[V],y:p[V],radius:7+pe*11,color:ie>=1&&ie<=kn.length?kn[ie-1]:"#64748b",rank:R[V],isInterface:Ri.test(v.id)}}),S=g.map(v=>({source:v.source,target:v.target,backward:R[v.sourceIndex]>=R[v.targetIndex]}));return{nodes:$,edges:S,width:J,height:se,maxRank:H,maxLayerCount:z,backwardCount:S.filter(v=>v.backward).length,interfaceCount:$.filter(v=>v.isInterface).length}}function wn(t,n,s){return Math.min(s,Math.max(n,t))}function Na({graph:t}){let n=Ke(()=>Ia(t),[t]),[s,c]=l(""),a=Ke(()=>Hi(n),[n]),o=Ke(()=>Oi(n),[n]),f=Ke(()=>[...a,...o],[a,o]),r=Ke(()=>La(n),[n]),g=Ke(()=>{if(!s)return null;let $=f.find(v=>v.id===s);if(!$)return null;let S=new Set($.nodeIDs);for(let v of $.nodeIDs)for(let V of r.get(v)??[])S.add(V);return S},[s,f,r]),w=Ke(()=>g?{...t,nodes:t.nodes.filter($=>g.has($.id)),edges:t.edges.filter($=>g.has($.source)&&g.has($.target))}:t,[t,g]),d=Ke(()=>Ia(w),[w]),[k,h]=l(()=>({x:0,y:0,w:d.width,h:d.height})),[L,T]=l(!1),[m,y]=l(null),[C,E]=l(!1),P=ee(null),A=ee(null);K(()=>{s&&!f.some($=>$.id===s)&&c("")},[f,s]),K(()=>{A.current=null,T(!1),y(null),h({x:0,y:0,w:d.width,h:d.height})},[d]);let b=$=>{let{x:v,y:V,w:ie,h:pe}=$;return ie>=d.width?v=(d.width-ie)/2:v=wn(v,-40,d.width-ie+40),pe>=d.height?V=(d.height-pe)/2:V=wn(V,-40,d.height-pe+40),{x:v,y:V,w:ie,h:pe}},_=($,S,v)=>{h(V=>{let ie=Math.max($i,d.width*.06),pe=d.width*1.08,ne=wn(V.w/$,ie,pe)/V.w,me=V.w*ne,ue=V.h*ne;return b({x:S-(S-V.x)*ne,y:v-(v-V.y)*ne,w:me,h:ue})})},R=($,S)=>{let v=P.current;if(!v)return{x:0,y:0};let V=v.getScreenCTM();if(!V)return{x:0,y:0};let ie=v.createSVGPoint();ie.x=$,ie.y=S;let pe=ie.matrixTransform(V.inverse());return{x:pe.x,y:pe.y}},H=()=>{h({x:0,y:0,w:d.width,h:d.height})},G=$=>{$.preventDefault();let S=R($.clientX,$.clientY);_(Math.exp(-$.deltaY*.0015),S.x,S.y)},re=$=>{if($.pointerType==="mouse"&&$.button!==0)return;let S=P.current;if(!S)return;let v=S.getScreenCTM();v&&($.preventDefault(),S.setPointerCapture($.pointerId),A.current={pointerId:$.pointerId,startX:$.clientX,startY:$.clientY,startView:k,scaleX:v.a||1,scaleY:v.d||1},T(!0))},x=$=>{let S=A.current;if(!S||S.pointerId!==$.pointerId)return;let v=(S.startX-$.clientX)/S.scaleX,V=(S.startY-$.clientY)/S.scaleY;h(b({...S.startView,x:S.startView.x+v,y:S.startView.y+V}))},F=$=>{let S=A.current;if(!S||S.pointerId!==$.pointerId)return;A.current=null,T(!1);let v=P.current;v?.hasPointerCapture($.pointerId)&&v.releasePointerCapture($.pointerId)},B=Ke(()=>new Map(d.nodes.map($=>[$.id,$])),[d]),z=Ke(()=>La(d),[d]);if(d.nodes.length===0)return e("div",{class:"empty vpp-graph-empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No active VPP nodes"}),e("p",{children:"Generate traffic or refresh after VPP has processed packets since the last stats clear."})]});let se=d.nodes.length<=40||C||m!==null,ge=m?z.get(m)??new Set([m]):null,p=m&&ge?Math.max(0,ge.size-1):0,N=Math.max(9,Math.min(26,k.w/70)),X=Math.max(1,Math.round(d.width/k.w*100)),ae=m?B.get(m):null;return e("div",{class:"vpp-graph-wrap",children:[e("div",{class:"vpp-graph-meta",children:[e("span",{children:[d.nodes.length," active node",d.nodes.length===1?"":"s"," \xB7 ",d.edges.length," ","edge",d.edges.length===1?"":"s",d.backwardCount>0?` \xB7 ${d.backwardCount} feedback`:"",d.interfaceCount>0?` \xB7 ${d.interfaceCount} interface${d.interfaceCount===1?"":"s"}`:""]}),e("span",{class:"vpp-graph-legend",children:[e("i",{class:"vpp-legend-node"}),"node = cycles/packet",e("i",{class:"vpp-legend-packet"}),"color = packets/call",e("i",{class:"vpp-legend-interface"}),"ring = interface",ae?` \xB7 ${ae.label}${ae.isInterface?" (interface)":""} \xB7 ${p} connected`:" \xB7 hover to isolate connections"]})]}),e("div",{class:"vpp-graph-toolbar",children:[e("span",{class:"vpp-graph-hint",children:["Inputs at top \xB7 outputs at bottom \xB7 drag to pan \xB7 scroll to zoom",d.backwardCount>0?" \xB7 dashed edge = feedback/self-loop":""]}),e("div",{class:"vpp-graph-controls",children:[f.length>0?e("label",{class:"vpp-interface-picker",children:[e("span",{children:"Focus"}),e("select",{value:s,title:"Show only nodes related to this interface or packet feature",onChange:$=>c($.currentTarget.value),children:[e("option",{value:"",children:"All nodes"}),a.length>0?e("optgroup",{label:"Interfaces",children:a.map($=>e("option",{value:$.id,children:$.label},$.id))}):null,o.length>0?e("optgroup",{label:"Packet features",children:o.map($=>e("option",{value:$.id,children:$.label},$.id))}):null]})]}):null,e("div",{class:"vpp-graph-zoom",children:[e("button",{type:"button","aria-label":"Zoom out",title:"Zoom out",onClick:()=>_(1/1.35,k.x+k.w/2,k.y+k.h/2),children:"\u2212"}),e("span",{children:[X,"%"]}),e("button",{type:"button","aria-label":"Zoom in",title:"Zoom in",onClick:()=>_(1.35,k.x+k.w/2,k.y+k.h/2),children:"+"}),e("button",{type:"button",onClick:H,children:"Fit"}),d.nodes.length>40?e("button",{type:"button","aria-pressed":C,class:C?"is-active":"",onClick:()=>E($=>!$),children:"Labels"}):null]})]})]}),e("svg",{ref:P,class:`vpp-graph${L?" is-panning":""}`,viewBox:`${k.x} ${k.y} ${k.w} ${k.h}`,role:"img","aria-label":"Active VPP packet processing graph, inputs at top and outputs at bottom",onWheel:G,onPointerDown:re,onPointerMove:x,onPointerUp:F,onPointerCancel:F,onLostPointerCapture:()=>{A.current=null,T(!1)},children:[e("defs",{children:e("marker",{id:"vpp-graph-arrow",viewBox:"0 0 10 10",refX:"9",refY:"5",markerWidth:"6",markerHeight:"6",orient:"auto-start-reverse",children:e("path",{d:"M 0 0 L 10 5 L 0 10 z",fill:"#64748b"})})}),e("g",{class:"vpp-graph-edges",children:d.edges.map(($,S)=>{let v=B.get($.source),V=B.get($.target);if(!v||!V)return null;let ie=m!==null&&($.source===m||$.target===m),pe=m!==null&&!ie;if($.source===$.target){let q=v.radius+6;return e("path",{d:`M ${v.x-v.radius} ${v.y} C ${v.x-q*2.2} ${v.y-q*2.4}, ${v.x+q*2.2} ${v.y-q*2.4}, ${v.x+v.radius} ${v.y}`,class:`vpp-edge vpp-edge-back${ie?" vpp-edge-active":""}${pe?" vpp-edge-hidden":""}`,markerEnd:"url(#vpp-graph-arrow)",vectorEffect:"non-scaling-stroke"},`${$.source}->${$.target}-${S}`)}let fe=V.x-v.x,ne=V.y-v.y,me=Math.max(1,Math.sqrt(fe*fe+ne*ne)),ue=v.x+fe/me*(v.radius+2),u=v.y+ne/me*(v.radius+2),I=V.x-fe/me*(V.radius+5),U=V.y-ne/me*(V.radius+5),Q=U>=u?1:-1,Z=Math.max(24,Math.min(80,Math.abs(U-u)*.45));return e("path",{d:`M ${ue} ${u} C ${ue} ${u+Q*Z}, ${I} ${U-Q*Z}, ${I} ${U}`,class:`vpp-edge${$.backward?" vpp-edge-back":""}${ie?" vpp-edge-active":""}${pe?" vpp-edge-hidden":""}`,markerEnd:"url(#vpp-graph-arrow)",vectorEffect:"non-scaling-stroke"},`${$.source}->${$.target}-${S}`)})}),e("g",{class:"vpp-graph-nodes",children:d.nodes.map($=>{let S=$.x<160?"start":$.x>d.width-160?"end":"middle",v=S==="start"?$.x-$.radius:S==="end"?$.x+$.radius:$.x,V=ge===null||ge.has($.id);return e("g",{class:`${se?"vpp-node":"vpp-node vpp-node-hover-label"}${V?"":" vpp-node-hidden"}`,onMouseEnter:()=>y($.id),onMouseLeave:()=>y(null),children:[$.isInterface?e("circle",{class:"vpp-interface-ring",cx:$.x,cy:$.y,r:$.radius+4,vectorEffect:"non-scaling-stroke"}):null,e("circle",{cx:$.x,cy:$.y,r:$.radius,fill:$.color,vectorEffect:"non-scaling-stroke",children:e("title",{children:[$.label,$.isInterface?" (interface)":""]})}),e("text",{x:v,y:$.y+$.radius+N*1.25,textAnchor:S,style:{fontSize:`${N}px`},children:$.label})]},$.id)})})]}),e("div",{class:"vpp-graph-palette","aria-hidden":"true",children:[e("span",{children:"packets/call"}),kn.map(($,S)=>e("i",{style:{background:$},title:`${S+1}`},$)),e("span",{class:"vpp-graph-palette-note",children:"YlOrRd"})]})]})}function Ot(t,n){return`pipe${t.instance}.${n}`}function pt(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function Ut(t){let n=Math.trunc(Number(t)||0);return n>=1024**3?`${Math.trunc(n/1024**3)} GB`:n>=1024**2?`${Math.trunc(n/1024**2)} MB`:n>=1024?`${Math.trunc(n/1024)} KB`:`${n} B`}var Cn=!1,Da=0,Aa=[{id:"hosts",label:"Host interfaces"},{id:"hosttaps",label:"Host taps"},{id:"networks",label:"Networks"},{id:"pipes",label:"BD pipes"},{id:"interfaces",label:"Interfaces"},{id:"acl",label:"ACLs"},{id:"routes",label:"Route tables"},{id:"troubleshoot",label:"Troubleshoot"}];function xa(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l([]),[f,r]=l([]),[g,w]=l([]),[d,k]=l([]),[h,L]=l([]),[T,m]=l([]),[y,C]=l(!1),[E,P]=l(!0),[A,b]=l(""),[_,R]=l(""),[H,G]=l(""),[re,x]=l(!1),[F,B]=l(""),[z,J]=l(""),[se,ge]=l(""),[p,N]=l(!1),[X,ae]=l(""),[$,S]=l(""),[v,V]=l(""),[ie,pe]=l(!1),[fe,ne]=l(null),[me,ue]=l(!1),[u,I]=l(null),[U,Q]=l(null),[Z,q]=l(null),[_e,oe]=l(null),[he,ve]=l(null),[j,$e]=l([]),[O,we]=l(""),[Te,W]=l(!1),[ke,Se]=l(null),[De,He]=l(null),[Xe,ce]=l(null),[Re,Ae]=l(null),[Ie,Je]=l("hosts"),[xe,ft]=l(null),[rt,xn]=l(""),[Kt,Mn]=l(!1),jt=ee(!1),Vn=ee(0),[uo,po]=l([]),[Jt,$n]=l(""),[Rn,Fn]=l(!1),[fo,mo]=l({}),[ho,Bn]=l({}),[kt,Ct]=l(""),Xt=ee(!1),Zt=ee(null),Hn=Me("network"),On=Me("network_pipe"),Un=Me("host_interface"),qn=Me("host_tap"),Wn=Me("interface"),Gn=Me("acl"),zn=Me("route_table"),nt=je();K(()=>{let[i,D,Y]=nt.segments;if(D!=="hosttaps"&&N(!1),D!=="pipes"&&x(!1),D!=="hosts"&&D!=="hosttaps"&&q(null),i==="networks"){if(ue(!1),I(null),Q(null),oe(null),ve(null),W(!1),Se(null),ce(null),D==="new"){ne(null),pe(!0),Je("networks");return}if(D&&Y==="edit"){let de=t.find(be=>String(be.id)===D);if(de){ne(de),pe(!0),Je("networks");return}}pe(!1),ne(null),D&&Aa.some(de=>de.id===D)?Je(D):Je("networks");return}if(pe(!1),ne(null),i==="interfaces"){if(Je("interfaces"),W(!1),Se(null),D==="new"){ue(!0),I(null),Q(null),ve(null),ce(null);return}let de=g.find(be=>String(be.id)===D);ue(!1),Q(null),I(de&&Y==="attach"?de:null),ve(de&&Y==="acls"?de:null),ce(de&&Y==="route-tables"?de:null);return}if(i==="acls"){if(Je("acl"),ue(!1),I(null),Q(null),ve(null),ce(null),W(!1),Se(null),D==="new"){oe({acl:null});return}let de=d.find(be=>String(be.id)===D);oe(de&&Y==="edit"?{acl:de}:null);return}if(i==="route-tables"){if(Je("routes"),ue(!1),I(null),Q(null),oe(null),ve(null),ce(null),D==="new"){W(!0),Se(null);return}if(W(!1),Y==="routes"||!Y){let de=nt.query.get("ipv6")==="true",be=j.find(We=>String(We.id)===D&&We.ipv6===de);Se(be??null),be?nt.segments[3]==="new"?He({table:be}):nt.segments[3]&&nt.segments[4]==="edit"?He({table:be,routeID:Number(nt.segments[3])}):He(null):He(null)}else Se(null),He(null);return}ue(!1),He(null),I(null),Q(null),oe(null),ve(null),W(!1),Se(null),ce(null)},[nt.path,nt.search,g,d,t,j]);let qe=i=>{i.networks&&(n(i.networks),window.dispatchEvent(new CustomEvent("rookery:networks-changed",{detail:i.networks}))),i.interfaces&&(w(i.interfaces),window.dispatchEvent(new CustomEvent("rookery:interfaces-changed",{detail:i.interfaces}))),i.acls&&k(i.acls),i.vms&&L(i.vms),i.vnfs&&m(i.vnfs)},Kn=async(i=!0)=>{try{let D=await M("/api/networks"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"})),qe({networks:Y}),i&&b("")}catch(D){i&&b(D instanceof Error?D.message:String(D))}},bo=async(i=!0)=>{try{let D=await M("/api/network-pipes"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.id-be.id),c(Y),i&&R("")}catch(D){i&&R(D instanceof Error?D.message:String(D))}},Yt=async(i=!0)=>{try{let D=await M("/api/host/interfaces"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"})),o(Y),i&&B("")}catch(D){i&&B(D instanceof Error?D.message:String(D))}},go=async(i=!0)=>{try{let D=await M("/api/host/taps"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"})),r(Y),i&&J("")}catch(D){i&&J(D instanceof Error?D.message:String(D))}},vo=async i=>{if(window.confirm(`Delete host tap ${i.name}? The host will lose its connection to ${i.network_name}.`)){ge(i.name),J("");try{await M(`/api/host/taps/${i.id}`,{method:"DELETE"}),r(D=>D.filter(Y=>Y.id!==i.id)),le(`Deleted host tap ${i.name}`)}catch(D){let Y=D instanceof Error?D.message:String(D);J(`Could not delete ${i.name}: ${Y}`),le(`Could not delete host tap ${i.name}: ${Y}`,!0)}finally{ge("")}}},yo=async i=>{if(window.confirm(`Bring ${i.name} into VPP? -This flushes host IP addresses on the NIC; the host will lose connectivity on it.`)){ce(i.name),F("");try{await V(`/api/host/interfaces/${encodeURIComponent(i.name)}/attach`,{method:"POST"}),oe(`Attached ${i.name} to VPP`),await jt()}catch(D){F(D instanceof Error?D.message:String(D))}finally{ce("")}}},go=async i=>{if(window.confirm(`Detach ${i.name} from VPP? +This flushes host IP addresses on the NIC; the host will lose connectivity on it.`)){ae(i.name),B("");try{await M(`/api/host/interfaces/${encodeURIComponent(i.name)}/attach`,{method:"POST"}),le(`Attached ${i.name} to VPP`),await Yt()}catch(D){B(D instanceof Error?D.message:String(D))}finally{ae("")}}},_o=async i=>{if(window.confirm(`Detach ${i.name} from VPP? -The Linux NIC becomes available to the host again; you may need to renew its DHCP lease.`)){ce(i.name),F("");try{await V(`/api/host/interfaces/${encodeURIComponent(i.name)}`,{method:"DELETE"}),oe(`Detached ${i.name} from VPP`),await jt()}catch(D){F(D instanceof Error?D.message:String(D))}finally{ce("")}}},Kn=async(i=!0)=>{try{let D=await V("/api/interfaces"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"})),Be({interfaces:j}),i&&$("")}catch(D){i&&$(D instanceof Error?D.message:String(D))}},vo=async(i=!0)=>{try{let D=await V("/api/acls"),j=Array.isArray(D)?D:[];j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"})),Be({acls:j}),i&&U("")}catch(D){i&&U(D instanceof Error?D.message:String(D))}},ct=async(i=!0)=>{try{let D=await V("/api/route-tables"),j=(Array.isArray(D)?D:[]).map(re=>({...re,id:re.id??0,table_id:re.table_id??0,ipv6:!!re.ipv6,interface_count:re.interface_count??0,route_count:re.route_count??0}));$e(j),i&&_e("")}catch(D){i&&_e(D instanceof Error?D.message:String(D))}},Yt=async()=>{try{let i=await V("/api/vms");Be({vms:Array.isArray(i)?i:[]})}catch{Be({vms:[]})}},Jn=async()=>{try{let i=await V("/api/vnfs");Be({vnfs:Array.isArray(i)?i:[]})}catch{Be({vnfs:[]})}},yo=async()=>{try{let i=await V("/api/status");P(!!i.vpp?.ready)}catch{P(!1)}},rt=async()=>{S(!0),await Promise.allSettled([Gn(),fo(),jt(),mo(),Kn(),vo(),ct(),Yt(),Jn(),yo()]),S(!1)},Xn=async(i=!1)=>{if(!(Kt.current||Date.now()-Vn.current<(i?1e3:5e3))){Kt.current=!0,xn(!0);try{let j=await V("/api/troubleshoot/vpp-graph");ft(j),Dn("")}catch(j){Dn(j instanceof Error?j.message:String(j))}finally{Vn.current=Date.now(),Kt.current=!1,xn(!1)}}},Qt=async(i=!1)=>{let D=Date.now();if(!(kn||!i&&D-La<5e3)){kn=!0,Rn(!1);try{let j=await V("/api/troubleshoot/acl-counters");lo(Array.isArray(j)?j:[]),Mn("")}catch(j){Mn(j instanceof Error?j.message:String(j))}finally{kn=!1,La=Date.now(),Rn(!0)}}},Zn=async()=>{if(!Xt.current){Xt.current=!0;try{let i=await V("/api/interfaces/counters"),D=Array.isArray(i)?i:[],j=Date.now(),re=Zt.current,he={},He={},qe=re&&j>re.at?(j-re.at)/1e3:0;for(let Qe of D){let ut=Number(Qe.rx_bytes)||0,Xe=Number(Qe.tx_bytes)||0;he[Qe.name]={rx:ut,tx:Xe};let nt=re?.bytes[Qe.name];nt&&qe>0&&(He[Qe.name]={rx:ut>=nt.rx?(ut-nt.rx)/qe:0,tx:Xe>=nt.tx?(Xe-nt.tx)/qe:0})}Zt.current={at:j,bytes:he};let Ue={};for(let Qe of D)Ue[Qe.name]=Qe;uo(Ue),Fn(He),Ct("")}catch(i){Ct(i instanceof Error?i.message:String(i))}finally{Xt.current=!1}}};z(()=>{let i=tt.segments[0],D=i==="networks"||i==="interfaces"||i==="acls"||i==="route-tables",j=document.getElementById("networks-panel");if(Le!=="interfaces"||!D||!g||j!==null&&j.hidden)return;let re=()=>{document.hidden||Zn()};re();let he=window.setInterval(re,2e3);return()=>{window.clearInterval(he)}},[Le,tt.path,g]);let _o=async()=>{if(window.confirm(`Reset VPP interface counters? +The Linux NIC becomes available to the host again; you may need to renew its DHCP lease.`)){ae(i.name),B("");try{await M(`/api/host/interfaces/${encodeURIComponent(i.name)}`,{method:"DELETE"}),le(`Detached ${i.name} from VPP`),await Yt()}catch(D){B(D instanceof Error?D.message:String(D))}finally{ae("")}}},jn=async(i=!0)=>{try{let D=await M("/api/interfaces"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"})),qe({interfaces:Y}),i&&S("")}catch(D){i&&S(D instanceof Error?D.message:String(D))}},wo=async(i=!0)=>{try{let D=await M("/api/acls"),Y=Array.isArray(D)?D:[];Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"})),qe({acls:Y}),i&&V("")}catch(D){i&&V(D instanceof Error?D.message:String(D))}},dt=async(i=!0)=>{try{let D=await M("/api/route-tables"),Y=(Array.isArray(D)?D:[]).map(de=>({...de,id:de.id??0,table_id:de.table_id??0,ipv6:!!de.ipv6,interface_count:de.interface_count??0,route_count:de.route_count??0}));$e(Y),i&&we("")}catch(D){i&&we(D instanceof Error?D.message:String(D))}},Qt=async()=>{try{let i=await M("/api/vms");qe({vms:Array.isArray(i)?i:[]})}catch{qe({vms:[]})}},Jn=async()=>{try{let i=await M("/api/vnfs");qe({vnfs:Array.isArray(i)?i:[]})}catch{qe({vnfs:[]})}},ko=async()=>{try{let i=await M("/api/status");C(!!i.vpp?.ready)}catch{C(!1)}},lt=async()=>{P(!0),await Promise.allSettled([Kn(),bo(),Yt(),go(),jn(),wo(),dt(),Qt(),Jn(),ko()]),P(!1)},Xn=async(i=!1)=>{if(!(jt.current||Date.now()-Vn.current<(i?1e3:5e3))){jt.current=!0,Mn(!0);try{let Y=await M("/api/troubleshoot/vpp-graph");ft(Y),xn("")}catch(Y){xn(Y instanceof Error?Y.message:String(Y))}finally{Vn.current=Date.now(),jt.current=!1,Mn(!1)}}},en=async(i=!1)=>{let D=Date.now();if(!(Cn||!i&&D-Da<5e3)){Cn=!0,Fn(!1);try{let Y=await M("/api/troubleshoot/acl-counters");po(Array.isArray(Y)?Y:[]),$n("")}catch(Y){$n(Y instanceof Error?Y.message:String(Y))}finally{Cn=!1,Da=Date.now(),Fn(!0)}}},Zn=async()=>{if(!Xt.current){Xt.current=!0;try{let i=await M("/api/interfaces/counters"),D=Array.isArray(i)?i:[],Y=Date.now(),de=Zt.current,be={},We={},ze=de&&Y>de.at?(Y-de.at)/1e3:0;for(let tt of D){let ut=Number(tt.rx_bytes)||0,Ze=Number(tt.tx_bytes)||0;be[tt.name]={rx:ut,tx:Ze};let st=de?.bytes[tt.name];st&&ze>0&&(We[tt.name]={rx:ut>=st.rx?(ut-st.rx)/ze:0,tx:Ze>=st.tx?(Ze-st.tx)/ze:0})}Zt.current={at:Y,bytes:be};let Ge={};for(let tt of D)Ge[tt.name]=tt;mo(Ge),Bn(We),Ct("")}catch(i){Ct(i instanceof Error?i.message:String(i))}finally{Xt.current=!1}}};K(()=>{let i=nt.segments[0],D=i==="networks"||i==="interfaces"||i==="acls"||i==="route-tables",Y=document.getElementById("networks-panel");if(Ie!=="interfaces"||!D||!y||Y!==null&&Y.hidden)return;let de=()=>{document.hidden||Zn()};de();let be=window.setInterval(de,2e3);return()=>{window.clearInterval(be)}},[Ie,nt.path,y]);let Co=async()=>{if(window.confirm(`Reset VPP interface counters? -This clears the RX/TX counters for all VPP interfaces.`))try{await V("/api/interfaces/counters",{method:"DELETE"}),Zt.current=null,Fn({}),Ct(""),oe("Reset interface counters"),Zn()}catch(i){let D=i instanceof Error?i.message:String(i);Ct(D),oe(`Could not reset interface counters: ${D}`,!0)}};z(()=>{Le==="troubleshoot"&&Xn()},[Le]),z(()=>{rt();let i=qe=>{P(!!qe.detail)},D=()=>{Q("/networks/new")},j=qe=>{let Ue=qe.detail;Array.isArray(Ue)&&w(Ue)},re=qe=>{let Ue=qe.detail;Array.isArray(Ue)&&m(Ue)},he=qe=>{qe.detail==="networks"&&rt()},He=()=>{Gn(!1)};return window.addEventListener("rookery:vpp-ready",i),window.addEventListener("rookery:add-network",D),window.addEventListener("rookery:interfaces-data",j),window.addEventListener("rookery:vnfs-data",re),window.addEventListener("rookery:vnfs-changed",re),window.addEventListener("rookery:tab-changed",he),window.addEventListener("rookery:refresh-networks",He),()=>{window.removeEventListener("rookery:vpp-ready",i),window.removeEventListener("rookery:add-network",D),window.removeEventListener("rookery:interfaces-data",j),window.removeEventListener("rookery:vnfs-data",re),window.removeEventListener("rookery:vnfs-changed",re),window.removeEventListener("rookery:tab-changed",he),window.removeEventListener("rookery:refresh-networks",He)}},[]),z(()=>{let i=document.getElementById("network-tab-count");i&&(i.textContent=String(t.length))},[t.length]),z(()=>{let i=document.getElementById("new-network");i&&(i.disabled=!g,i.title=g?"":"Network creation is blocked until FD.io VPP is ready.")},[g]);let wo=async i=>{if(b.filter(he=>he.network_id===i.id).length>0){oe("Delete the virtual interfaces in this network first.",!0);return}let j=I.filter(he=>he.network_id===i.id);if(window.confirm(`Delete Rookery Network "${i.name}"? +This clears the RX/TX counters for all VPP interfaces.`))try{await M("/api/interfaces/counters",{method:"DELETE"}),Zt.current=null,Bn({}),Ct(""),le("Reset interface counters"),Zn()}catch(i){let D=i instanceof Error?i.message:String(i);Ct(D),le(`Could not reset interface counters: ${D}`,!0)}};K(()=>{Ie==="troubleshoot"&&Xn()},[Ie]),K(()=>{lt();let i=ze=>{C(!!ze.detail)},D=()=>{te("/networks/new")},Y=ze=>{let Ge=ze.detail;Array.isArray(Ge)&&w(Ge)},de=ze=>{let Ge=ze.detail;Array.isArray(Ge)&&m(Ge)},be=ze=>{ze.detail==="networks"&<()},We=()=>{Kn(!1)};return window.addEventListener("rookery:vpp-ready",i),window.addEventListener("rookery:add-network",D),window.addEventListener("rookery:interfaces-data",Y),window.addEventListener("rookery:vnfs-data",de),window.addEventListener("rookery:vnfs-changed",de),window.addEventListener("rookery:tab-changed",be),window.addEventListener("rookery:refresh-networks",We),()=>{window.removeEventListener("rookery:vpp-ready",i),window.removeEventListener("rookery:add-network",D),window.removeEventListener("rookery:interfaces-data",Y),window.removeEventListener("rookery:vnfs-data",de),window.removeEventListener("rookery:vnfs-changed",de),window.removeEventListener("rookery:tab-changed",be),window.removeEventListener("rookery:refresh-networks",We)}},[]),K(()=>{let i=document.getElementById("network-tab-count");i&&(i.textContent=String(t.length))},[t.length]),K(()=>{let i=document.getElementById("new-network");i&&(i.disabled=!y,i.title=y?"":"Network creation is blocked until FD.io VPP is ready.")},[y]);let So=async i=>{if(g.filter(be=>be.network_id===i.id).length>0){le("Delete the virtual interfaces in this network first.",!0);return}let Y=T.filter(be=>be.network_id===i.id);if(window.confirm(`Delete Rookery Network "${i.name}"? -This removes the VPP bridge domain and ${j.length} VNF(s).`))try{await V(`/api/networks/${i.id}`,{method:"DELETE"});let he=t.filter(He=>He.id!==i.id);Be({networks:he}),p(""),oe(`Deleted network ${i.name}`),Jn()}catch(he){p(`Could not delete ${i.name}: ${he instanceof Error?he.message:String(he)}`)}},ko=async i=>{if(window.confirm(`Delete pipe between ${i.network_a_name} and ${i.network_b_name}?`)){J(String(i.id)),H("");try{await V(`/api/network-pipes/${i.id}`,{method:"DELETE"}),l(D=>D.filter(j=>j.id!==i.id)),oe(`Deleted pipe ${i.network_a_name} \u2194 ${i.network_b_name}`)}catch(D){H(D instanceof Error?D.message:String(D))}finally{J("")}}},Co=async i=>{if(i.attached_vm_id||i.attached_lightvm_id){oe("Detach the interface from its VM or LightVM first.",!0);return}if(window.confirm(`Delete virtual interface "${i.name}"? +This removes the VPP bridge domain and ${Y.length} VNF(s).`))try{await M(`/api/networks/${i.id}`,{method:"DELETE"});let be=t.filter(We=>We.id!==i.id);qe({networks:be}),b(""),le(`Deleted network ${i.name}`),Jn()}catch(be){b(`Could not delete ${i.name}: ${be instanceof Error?be.message:String(be)}`)}},To=async i=>{if(window.confirm(`Delete pipe between ${i.network_a_name} and ${i.network_b_name}?`)){G(String(i.id)),R("");try{await M(`/api/network-pipes/${i.id}`,{method:"DELETE"}),c(D=>D.filter(Y=>Y.id!==i.id)),le(`Deleted pipe ${i.network_a_name} \u2194 ${i.network_b_name}`)}catch(D){R(D instanceof Error?D.message:String(D))}finally{G("")}}},Po=async i=>{if(i.attached_vm_id||i.attached_lightvm_id){le("Detach the interface from its VM or LightVM first.",!0);return}if(window.confirm(`Delete virtual interface "${i.name}"? -This removes the VPP vhost-user interface and socket.`))try{await V(`/api/interfaces/${i.id}`,{method:"DELETE"}),Be({interfaces:b.filter(j=>j.id!==i.id)}),$(""),oe(`Deleted interface ${i.name}`)}catch(j){$(`Could not delete ${i.name}: ${j instanceof Error?j.message:String(j)}`)}},So=async i=>{let D=i.attached_vm_id?h.find(j=>j.id===i.attached_vm_id):null;if(D&&(D.status==="running"||D.status==="starting")){oe("Stop the VM before detaching its interface.",!0);return}if(window.confirm(`Detach interface "${i.name}" from its VM?`))try{let j=await V(`/api/interfaces/${i.id}`,{method:"PUT",body:JSON.stringify({attached_vm_id:null})});Be({interfaces:b.map(re=>re.id===j.id?j:re)}),$(""),oe(`Detached ${i.name}`)}catch(j){$(`Could not detach ${i.name}: ${j instanceof Error?j.message:String(j)}`)}},To=async i=>{if(Bt(i,b).length>0){oe(`Remove ACL ${i.name} from its interfaces first.`,!0);return}if(window.confirm(`Delete ACL "${i.name}"?`))try{await V(`/api/acls/${i.id}`,{method:"DELETE"}),Be({acls:u.filter(D=>D.id!==i.id)}),U(""),oe(`Deleted ACL ${i.name}`)}catch(D){U(`Could not delete ${i.name}: ${D instanceof Error?D.message:String(D)}`),oe(`Could not delete ACL ${i.name}: ${D instanceof Error?D.message:String(D)}`,!0)}},Po=async i=>{if(!i.is_default&&window.confirm(`Delete route table "${i.name}" (${i.table_id}/${i.ipv6?"IPv6":"IPv4"})?`))try{await V(`/api/route-tables/${i.id}`,{method:"DELETE"}),await ct(),oe(`Deleted route table ${i.name}`)}catch(D){let j=D instanceof Error?D.message:String(D);_e(`Could not delete ${i.name}: ${j}`),oe(`Could not delete route table ${i.name}: ${j}`,!0)}},Eo=i=>{window.dispatchEvent(new CustomEvent("rookery:open-tab",{detail:"vnfs"})),window.dispatchEvent(new CustomEvent("rookery:add-vnf",{detail:{networkId:i.id}}))},Lo=b.filter(i=>i.attached_vm_id||i.attached_lightvm_id).length,Io=u.filter(i=>Bt(i,b).length>0).length,Ao=t.length===0?L?"Loading networks\u2026":"No Rookery Networks registered":`${t.length} network${t.length===1?"":"s"} \xB7 ${b.length} interface${b.length===1?"":"s"} \xB7 ${I.length} VNF${I.length===1?"":"s"}`,No=a.length===0?L?"Loading host interfaces\u2026":"No host interfaces found":`${a.length} host interface${a.length===1?"":"s"} \xB7 ${a.filter(i=>i.in_vpp).length} in VPP`,Do=f.length===0?L?"Loading host taps\u2026":"No network host taps":`${f.length} host tap${f.length===1?"":"s"} \xB7 ${new Set(f.map(i=>i.network_id)).size} network${new Set(f.map(i=>i.network_id)).size===1?"":"s"}`,xo=b.length===0?L?"Loading interfaces\u2026":"No virtual interfaces registered":`${b.length} interface${b.length===1?"":"s"} \xB7 ${Lo} attached`,Vo=u.length===0?L?"Loading ACLs\u2026":"No ACLs defined":`${u.length} ACL${u.length===1?"":"s"} \xB7 ${Io} assigned`,jn=i=>!i||!i.acl_indexes||i.acl_indexes.length===0?"no ACL":`${Ye(i.matched)} matched \xB7 ${Ye(i.denied)} denied \xB7 ${Ye(i.permitted)} permitted`,Mo=i=>{let D=i.acl_index??0,j=i.rule_index??0,he=u.find(He=>He.vpp_index===D)?.rules[j];return he?gn(he):`ACL ${D} rule ${j}`},Yn=(i,D)=>{let j=D?.rules||[];return e("div",{class:"acl-rule-counter-group",children:[e("span",{class:"acl-counter-direction",children:i}),!D?.acl_indexes||D.acl_indexes.length===0?e("span",{class:"acl-rule-counter muted",children:"No ACL applied"}):j.length===0?e("span",{class:"acl-rule-counter muted",children:"No explicit rule matches yet"}):j.map((re,he)=>e("span",{class:"acl-rule-counter",children:[e("span",{class:"acl-rule-counter-text",children:Mo(re)}),e("span",{class:"acl-rule-counter-value",children:[Ye(re.matched)," pkts \xB7 ",re.action||"\u2014"]})]},`${re.acl_index??0}-${re.rule_index??0}-${he}`))]})};return e(ge,{children:[e("div",{class:"subtabs",role:"tablist","aria-label":"Network sections",children:Ia.map(i=>e("button",{class:"subtab",type:"button",role:"tab","aria-selected":Le===i.id,onClick:()=>{Q(`/networks/${i.id}`),i.id==="interfaces"&&Qt()},children:i.label},i.id))}),e("section",{class:"panel","aria-labelledby":"networks-heading",hidden:Le!=="networks",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"networks-heading",children:"Network"}),e("p",{class:"panel-subtitle",children:Ao})]}),e("button",{class:"button ghost",type:"button",disabled:L,onClick:()=>{rt()},children:L?"Refreshing\u2026":"Refresh"})]}),N?e("div",{class:"banner",role:"alert",children:N}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Bridge domain"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",children:"VNFs"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(i=>{let D=b.filter(re=>re.network_id===i.id).length,j=I.filter(re=>re.network_id===i.id);return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),e("span",{class:"vm-description mono",children:[i.ipv4_cidr||"no IPv4 CIDR"," \xB7 ",i.ipv6_cidr||"no IPv6 CIDR"]}),i.bvi_host_name?e("span",{class:"vm-description",children:[i.bvi_host_name,i.ipv4_gateway?` \xB7 ${i.ipv4_gateway}`:""]}):null,e(De,{entityType:"network",entityID:i.id,name:i.name,labels:Bn.labels[String(i.id)],onSaved:re=>Bn.setEntityLabels(String(i.id),re)})]}),e("td",{class:"mono",children:["bd ",i.bridge_domain_id]}),e("td",{class:D?"mono":"mono muted",children:String(D)}),e("td",{class:j.length?"mono":"mono muted",children:j.length?j.map(re=>`${re.name} (${re.type})`).join(", "):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>Q(`/networks/${i.id}/edit`),children:"Edit CIDRs"}),e("button",{class:"row-action",type:"button",onClick:()=>Eo(i),children:"Add VNF"}),e("button",{class:"row-action danger",type:"button",disabled:D>0||!g,title:D>0?"Delete the virtual interfaces first.":g?"":"VPP must be ready to delete a bridge domain.",onClick:()=>{wo(i)},children:"Delete"})]})})]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2B22"}),e("h3",{children:"No Networks yet"}),e("p",{children:"Create a VPP bridge domain to connect virtual interfaces and VMs."}),e("button",{class:"button primary",type:"button",disabled:!g,onClick:()=>Q("/networks/new"),children:"Create a network"})]})})]}),e("section",{class:"panel","aria-labelledby":"pipes-heading",hidden:Le!=="pipes",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"pipes-heading",children:"Bridge-domain pipes"}),e("p",{class:"panel-subtitle",children:"Connect two Rookery Networks with a VPP pipe pair."})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:L,onClick:()=>{rt()},children:L?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!g||t.length<2,title:t.length<2?"Create at least two networks first":g?"":"VPP must be ready",onClick:()=>x(!0),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add pipe"]})]})]}),e("p",{class:"field-hint",children:"A pipe creates two VPP interfaces joined back-to-back. One end is placed in each bridge domain, providing an L2 bridge between the selected networks."}),t.length<2?e("p",{class:"field-hint",children:"Create at least two networks before adding a pipe."}):null,T?e("div",{class:"banner",role:"alert",children:T}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Network A"}),e("th",{scope:"col",children:"Network B"}),e("th",{scope:"col",children:"VPP instance"}),e("th",{scope:"col",children:"Endpoints"}),e("th",{scope:"col",class:"hide-sm",children:"Created"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:s.map(i=>e("tr",{children:[e("td",{children:e("span",{class:"vm-name",children:i.network_a_name})}),e("td",{children:[e("span",{class:"vm-name",children:i.network_b_name}),e(De,{entityType:"network_pipe",entityID:i.id,name:`${i.network_a_name} \u2194 ${i.network_b_name}`,labels:Hn.labels[String(i.id)],onSaved:D=>Hn.setEntityLabels(String(i.id),D)})]}),e("td",{class:"mono",children:["pipe",i.instance]}),e("td",{class:"mono",children:[e("span",{children:[Ot(i,0)," (",i.endpoint_a_sw_if_index||"\u2014",")"]}),e("span",{children:" \u2194 "}),e("span",{children:[Ot(i,1)," (",i.endpoint_b_sw_if_index||"\u2014",")"]})]}),e("td",{class:"updated hide-sm",children:pt(i.created_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:!g||!i.endpoint_a_sw_if_index,onClick:()=>X({name:Ot(i,0),label:`pipe endpoint A \xB7 ${i.network_a_name}`}),children:"A addresses"}),e("button",{class:"button ghost",type:"button",disabled:!g||!i.endpoint_b_sw_if_index,onClick:()=>X({name:Ot(i,1),label:`pipe endpoint B \xB7 ${i.network_b_name}`}),children:"B addresses"}),e("button",{class:"row-action danger",type:"button",disabled:G===String(i.id)||!g,onClick:()=>{ko(i)},children:G===String(i.id)?"Deleting\u2026":"Delete"})]})})]},i.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2194"}),e("h3",{children:"No bridge-domain pipes"}),e("p",{children:"Connect two Rookery Networks to pass L2 traffic between their bridge domains."}),e("button",{class:"button primary",type:"button",disabled:!g||t.length<2,onClick:()=>x(!0),children:"Add pipe"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"hosts-heading",hidden:Le!=="hosts",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"hosts-heading",children:"Host interfaces"}),e("p",{class:"panel-subtitle",children:No})]}),e("button",{class:"button ghost",type:"button",disabled:L,onClick:()=>{rt()},children:L?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:["Physical NICs and external interfaces visible to the server; Rookery/VPP taps and host interfaces are hidden. \u201CIn VPP\u201D means a matching",e("code",{children:"host-"})," interface currently exists in VPP; the VPP state is shown as unknown when VPP cannot be queried. Attach takes a physical NIC into VPP (flushing its host addresses); persisted attachments are recreated after a VPP restart."]}),R?e("div",{class:"banner",role:"alert",children:R}):null,e("div",{class:"table-wrap",children:a.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Host / VPP addresses"}),e("th",{scope:"col",children:"Link"}),e("th",{scope:"col",children:"VPP"}),e("th",{scope:"col",children:"Default route"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:a.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:i.name}),e(De,{entityType:"host_interface",entityID:i.name,name:i.name,labels:On.labels[i.name],onSaved:D=>On.setEntityLabels(i.name,D)})]}),e("td",{class:"mono",children:e("div",{class:"address-stack",children:[e("span",{children:[e("span",{class:"address-side",children:"Host"}),i.addresses&&i.addresses.length>0?i.addresses.join(", "):"\u2014"]}),i.in_vpp?e("span",{children:[e("span",{class:"address-side",children:"VPP"}),i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"]}):null]})}),e("td",{children:e("span",{class:"status-badge","data-status":i.up?"running":"disabled",children:i.up?"up":"down"})}),e("td",{children:i.vpp_checked?i.in_vpp?e(ge,{children:[e("span",{class:"status-badge","data-status":i.vpp_up?"running":"degraded",children:"in VPP"}),e("span",{class:"vm-description mono",children:[i.vpp_name||`host-${i.name}`,typeof i.vpp_sw_if_index=="number"?` \xB7 sw_if_index ${i.vpp_sw_if_index}`:"",i.managed?" \xB7 persisted":""]})]}):e("span",{class:"status-badge","data-status":"disabled",children:"not in VPP"}):e("span",{class:"status-badge","data-status":"disabled",children:"unknown"})}),e("td",{class:"mono",children:i.default_route?"yes":"\u2014"}),e("td",{children:i.wireless?"wireless":i.physical?"physical":"virtual"}),e("td",{children:e("div",{class:"actions",children:i.in_vpp?e(ge,{children:[i.managed?e(ge,{children:[e("button",{class:"button ghost",type:"button",disabled:!g,onClick:()=>X({name:i.vpp_name||`host-${i.name}`,label:"host interface",hostAddresses:i.addresses||[]}),children:"Addresses"}),e("button",{class:"button ghost",type:"button",disabled:!g,onClick:()=>fe({name:i.vpp_name||`host-${i.name}`,label:"Host interface"}),children:"Route tables"})]}):null,e("button",{class:"button ghost",type:"button",disabled:se===i.name||!g,onClick:()=>{go(i)},children:se===i.name?"Detaching\u2026":"Detach"})]}):e("button",{class:"button ghost",type:"button",disabled:se===i.name||!g||!i.physical||i.wireless||i.default_route,title:i.default_route?"The host default-route interface cannot be attached":i.wireless?"Wireless interfaces cannot be attached":i.physical?"Take this NIC into VPP":"Only physical interfaces can be attached",onClick:()=>{bo(i)},children:se===i.name?"Attaching\u2026":"Attach to VPP"})})})]},i.name))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No host interfaces found"}),e("p",{children:"Linux network interfaces will appear here when available."})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"hosttaps-heading",hidden:Le!=="hosttaps",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"hosttaps-heading",children:"Host taps"}),e("p",{class:"panel-subtitle",children:Do})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:L,onClick:()=>{rt()},children:L?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!g||t.length===0,onClick:()=>M(!0),children:"Add host tap"})]})]}),e("p",{class:"field-hint",children:"Host taps bridge a Rookery Network into a Linux tap interface on the server. Assign a host IPv4 address here (or use DHCP on the network) and the host can reach VMs and other machines on that network directly. Host taps are persisted and recreated after an API or VPP restart."}),W?e("div",{class:"banner",role:"alert",children:W}):null,e("div",{class:"table-wrap",children:f.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"Host / VPP address"}),e("th",{scope:"col",children:"VPP index"}),e("th",{scope:"col",children:"Created"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:f.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:i.name}),e(De,{entityType:"host_tap",entityID:i.id,name:i.name,labels:Un.labels[String(i.id)],onSaved:D=>Un.setEntityLabels(String(i.id),D)})]}),e("td",{children:[e("span",{class:"vm-name",children:i.network_name}),e("span",{class:"vm-description mono",children:["#",i.network_id]})]}),e("td",{class:"mono",children:e("div",{class:"address-stack",children:[e("span",{children:[e("span",{class:"address-side",children:"Host"}),i.ipv4_address||"\u2014"]}),e("span",{children:[e("span",{class:"address-side",children:"VPP"}),i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"]})]})}),e("td",{class:"mono",children:i.sw_if_index||"\u2014"}),e("td",{children:pt(i.created_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:!g,onClick:()=>X({name:i.name,label:"host tap",hostAddresses:i.ipv4_address?[i.ipv4_address]:[]}),children:"Addresses"}),e("button",{class:"button ghost",type:"button",disabled:!g||!i.sw_if_index,onClick:()=>fe({name:i.name,label:"Host tap"}),children:"Route tables"}),e("button",{class:"button ghost",type:"button",disabled:y===i.name||!g,onClick:()=>{ho(i)},children:y===i.name?"Deleting\u2026":"Delete"})]})})]},i.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No host taps"}),e("p",{children:"Connect a Rookery Network to the Linux host to reach its machines."}),e("button",{class:"button primary",type:"button",disabled:!g||t.length===0,onClick:()=>M(!0),children:"Add host tap"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"interfaces-heading",hidden:Le!=="interfaces",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"interfaces-heading",children:"Rookery Virtual Interfaces"}),e("p",{class:"panel-subtitle",children:[xo,g?e("span",{class:"live-indicator","data-live":kt?"false":"true",children:kt?"counters unavailable":"live counters"}):null]})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:()=>{Qt(!0)},children:"Refresh counters"}),e("button",{class:"button ghost",type:"button",disabled:!g,title:g?"":"VPP must be ready to reset interface counters.",onClick:()=>{_o()},children:"Reset counters"}),e("button",{class:"button primary",type:"button",disabled:t.length===0||!g,title:t.length===0?"Create a Rookery Network first":g?"":"Interface creation is blocked until FD.io VPP is ready",onClick:()=>Q("/interfaces/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add interface"]})]})]}),v?e("div",{class:"banner",role:"alert",children:v}):null,kt?e("p",{class:"field-hint",children:["Live counters unavailable: ",kt]}):null,e("div",{class:"table-wrap",children:b.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"MAC"}),e("th",{scope:"col",children:"VPP addresses"}),e("th",{scope:"col",children:"Attached to"}),e("th",{scope:"col",children:"TX/RX"}),e("th",{scope:"col",children:"Speed"}),e("th",{scope:"col",children:"ACLs"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:b.map(i=>{let D=i.attached_vm_id?h.find(mt=>mt.id===i.attached_vm_id):null,j=!!i.attached_vm_id,re=!!(i.attached_vm_id||i.attached_lightvm_id),he=co[i.name],He=po[i.name],qe=!!(D&&(D.status==="running"||D.status==="starting")),Ue=[],Qe=i.ingress_acls?.length?i.ingress_acls:i.ingress_acl_name?[i.ingress_acl_name]:[],ut=i.egress_acls?.length?i.egress_acls:i.egress_acl_name?[i.egress_acl_name]:[];Qe.length>0&&Ue.push(`from: ${Qe.join(" \u2192 ")}`),ut.length>0&&Ue.push(`to: ${ut.join(" \u2192 ")}`);let Xe=ro.find(mt=>mt.interface===i.name),nt=dt===i.id;return e(ge,{children:[e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.socket_path?e("span",{class:"vm-description",title:i.socket_path,children:i.socket_path}):null,e("span",{class:"vm-description",children:["FIB v4: ",i.ipv4_table_name||"default"," \xB7 v6:"," ",i.ipv6_table_name||"default"]}),e(De,{entityType:"interface",entityID:i.id,name:i.name,labels:qn.labels[String(i.id)],onSaved:mt=>qn.setEntityLabels(String(i.id),mt)})]}),e("td",{class:"mono",children:i.network_name||"\u2014"}),e("td",{class:"mono",children:i.mac||"\u2014"}),e("td",{class:"mono",children:i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"}),e("td",{class:re?"mono":"mono muted",children:D?D.name:i.attached_vm_name||(i.attached_lightvm_name?`${i.attached_lightvm_name} (LightVM)`:"unattached")}),e("td",{class:he?"mono traffic-cell":"mono muted traffic-cell",children:he?e(ge,{children:[e("span",{class:"traffic-line",children:["\u2193 ",Ut(he.rx_bytes??0)]}),e("span",{class:"traffic-line",children:["\u2191 ",Ut(he.tx_bytes??0)]})]}):"\u2014"}),e("td",{class:He?"mono traffic-cell":"mono muted traffic-cell",children:He?e(ge,{children:[e("span",{class:"traffic-line",children:["\u2193 ",Ut(He.rx),"/s"]}),e("span",{class:"traffic-line",children:["\u2191 ",Ut(He.tx),"/s"]})]}):"\u2014"}),e("td",{class:Ue.length?"mono":"mono muted",children:Ue.length?e("div",{class:"interface-acl-summary",children:[e("span",{class:"acl-assignment",children:Ue.join(" \xB7 ")}),Xe?e("span",{class:"acl-inline-counter",children:["from ",Ye(Xe.ingress?.matched)," \xB7 to"," ",Ye(Xe.egress?.matched)]}):$n?Jt?e("span",{class:"acl-inline-counter muted",children:"counters unavailable"}):null:e("span",{class:"acl-inline-counter muted",children:"loading counters\u2026"}),e("button",{class:"row-action",type:"button",onClick:()=>Ve(nt?null:i.id),children:nt?"Hide details":"ACL details"})]}):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!g,title:g?"":"VPP must be ready to set interface addresses.",onClick:()=>X({name:i.name,label:"vhost-user"}),children:"Addresses"}),e("button",{class:"row-action",type:"button",disabled:!!i.attached_lightvm_id,title:i.attached_lightvm_id?"Remove the interface from its LightVM first.":"",onClick:()=>Q(`/interfaces/${i.id}/attach`),children:"Attach"}),j?e("button",{class:"row-action",type:"button",disabled:qe,title:qe?"Stop the VM before detaching its interface.":"",onClick:()=>{So(i)},children:"Detach"}):null,!j&&i.attached_lightvm_id?e("button",{class:"row-action",type:"button",onClick:()=>Q(`/lightvm/instances/${i.attached_lightvm_id}/edit`),children:"Edit LightVM"}):null,e("button",{class:"row-action",type:"button",disabled:!g,title:g?"":"VPP must be ready to apply interface ACLs.",onClick:()=>Q(`/interfaces/${i.id}/acls`),children:"ACLs"}),e("button",{class:"row-action",type:"button",disabled:!g,title:g?"":"VPP must be ready to change interface FIB tables.",onClick:()=>Q(`/interfaces/${i.id}/route-tables`),children:"Route tables"}),e("button",{class:"row-action danger",type:"button",disabled:re||!g,title:re?"Detach the interface from its VM or LightVM first.":g?"":"VPP must be ready to delete a vhost-user interface.",onClick:()=>{Co(i)},children:"Delete"})]})})]}),nt&&Ue.length>0?e("tr",{class:"acl-details-row",children:e("td",{colSpan:10,children:e("div",{class:"interface-acl-details",children:[e("div",{class:"interface-acl-details-head",children:[e("strong",{children:["ACL details for ",i.name]}),e("button",{class:"button ghost",type:"button",onClick:()=>Q(`/interfaces/${i.id}/acls`),children:"Edit ACLs"})]}),Xe?e(ge,{children:[e("div",{class:"acl-details-summary",children:[e("span",{children:[e("b",{children:"From interface"})," ",jn(Xe.ingress)]}),e("span",{children:[e("b",{children:"To interface"})," ",jn(Xe.egress)]})]}),e("div",{class:"acl-rule-counter-list",children:[Yn("From interface rules",Xe.ingress),Yn("To interface rules",Xe.egress)]})]}):Jt?e("p",{class:"field-hint",children:["ACL counters unavailable: ",Jt]}):$n?e("p",{class:"field-hint",children:"No ACL counter entry for this interface yet. Generate traffic or refresh the interface list."}):e("p",{class:"field-hint",children:"Loading ACL counters\u2026"}),e("p",{class:"field-hint",children:"Counters show explicit ACL rule matches; implicit-deny packets are not included."})]})})}):null]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No virtual interfaces yet"}),e("p",{children:"Add a VPP vhost-user interface to a Rookery Network, then attach it to a VM."})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"acls-heading",hidden:Le!=="acl",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"acls-heading",children:"Network ACLs"}),e("p",{class:"panel-subtitle",children:Vo})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:L,onClick:()=>{rt()},children:L?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!g,title:g?"":"ACL changes are blocked until FD.io VPP is ready.",onClick:()=>Q("/acls/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add ACL"]})]})]}),C?e("div",{class:"banner",role:"alert",children:C}):null,e("div",{class:"table-wrap",children:u.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Title"}),e("th",{scope:"col",children:"Rules"}),e("th",{scope:"col",children:"Assigned to"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:u.map(i=>{let D=Bt(i,b);return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.description?e("span",{class:"vm-description",title:i.description,children:i.description}):null,e(De,{entityType:"acl",entityID:i.id,name:i.name,labels:Wn.labels[String(i.id)],onSaved:j=>Wn.setEntityLabels(String(i.id),j)})]}),e("td",{children:i.title||"\u2014"}),e("td",{class:"mono",children:[i.rules?.length||0," rule",i.rules?.length===1?"":"s"]}),e("td",{class:D.length?"mono":"mono muted",children:D.length?D.map(j=>j.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!g,title:g?"":"VPP must be ready to edit ACLs.",onClick:()=>Q(`/acls/${i.id}/edit`),children:"Edit / Preview"}),e("button",{class:"row-action danger",type:"button",disabled:D.length>0,title:D.length>0?"Remove this ACL from its interfaces first.":"",onClick:()=>{To(i)},children:"Delete"})]})})]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A4"}),e("h3",{children:"No ACLs yet"}),e("p",{children:"Define ordered ingress/egress rules, then assign an ACL to a virtual interface."}),e("button",{class:"button primary",type:"button",disabled:!g,onClick:()=>Q("/acls/new"),children:"Add an ACL"})]})})]}),e("section",{class:"panel panel-spaced","aria-label":"Route tables",hidden:Le!=="routes",children:xe?e(ga,{table:xe.table,routeID:xe.routeID,interfaces:b,bviInterfaces:t.filter(i=>!!i.bvi_host_name).map(i=>i.bvi_host_name||"").filter(Boolean),hostInterfaces:a,hostTaps:f,tables:K,onBack:()=>{Oe(null),Q(`/route-tables/${xe.table.id}?ipv6=${xe.table.ipv6}`,!0)},onSaved:()=>{let i=xe.table;Oe(null),Q(`/route-tables/${i.id}?ipv6=${i.ipv6}`,!0),oe(xe.routeID?`Saved route ${i.name}`:`Added route to ${i.name}`),ct(!1)}},`route-form-${xe.table.id}-${xe.routeID??"new"}`):we?e(ya,{table:we,onBack:()=>{Te(null),Q("/networks/routes",!0)},onChanged:()=>{ct(!1)}},`route-page-${we.ipv6?"v6":"v4"}-${we.id}`):Ee?e(va,{tables:K,onCancel:()=>{q(!1),Q("/networks/routes",!0)},onSaved:i=>{q(!1),Q("/networks/routes",!0),oe(`Created route table ${i.name}`),ct()}}):e(ge,{children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"routes-heading",children:"Route tables"}),e("p",{class:"panel-subtitle",children:"IP FIB / VRF definitions attachable to interfaces. Table 0 is the default VRF."})]}),e("button",{class:"button primary",type:"button",disabled:!g,onClick:()=>Q("/route-tables/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add route table"]})]}),O?e("div",{class:"banner",role:"alert",children:O}):null,e("div",{class:"table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Family"}),e("th",{scope:"col",children:"Table ID"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",children:"Routes"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:K.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.is_default?e("span",{class:"vm-description",children:"default VRF"}):null,i.network_id?e("span",{class:"vm-description",children:["network VRF \xB7 network ",i.network_id]}):null,i.is_default?null:e(De,{entityType:"route_table",entityID:i.id,name:i.name,labels:zn.labels[String(i.id)],onSaved:D=>zn.setEntityLabels(String(i.id),D)})]}),e("td",{class:"mono",children:i.ipv6?"IPv6":"IPv4"}),e("td",{class:"mono",children:i.table_id}),e("td",{class:i.interface_count?"mono":"mono muted",children:i.interface_count||0}),e("td",{class:i.route_count?"mono":"mono muted",children:i.route_count||0}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>Q(`/route-tables/${i.id}?ipv6=${i.ipv6}`),children:"Routes"}),i.is_default?e("span",{class:"muted",children:"built-in"}):i.managed?e("span",{class:"muted",children:"network-managed"}):e("button",{class:"row-action danger",type:"button",disabled:!g,onClick:()=>{Po(i)},children:"Delete"})]})})]},`${i.ipv6?"v6":"v4"}-${i.id}`))})]})})]})}),e("section",{class:"panel panel-spaced","aria-labelledby":"troubleshoot-heading",hidden:Le!=="troubleshoot",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"troubleshoot-heading",children:"Troubleshoot"}),e("p",{class:"panel-subtitle",children:"VPP packet processing graph"})]}),e("button",{class:"button ghost",type:"button",disabled:Gt,onClick:()=>{Xn(!0)},children:Gt?"Refreshing\u2026":"Refresh graph"})]}),e("p",{class:"field-hint",children:"Active VPP nodes since the last stats clear. Node size represents cycles per packet; color represents packets per call."}),it?e("div",{class:"banner",role:"alert",children:it}):null,Gt&&!Ae?e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"Loading VPP graph\u2026"})]}):Ae?e(Ea,{graph:Ae}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No graph loaded"}),e("p",{children:"Use Refresh graph to inspect the active VPP packet-processing nodes."})]}),Ae?.generated_at?e("p",{class:"field-hint",children:["Sampled ",pt(Ae.generated_at)]}):null]}),e("section",{class:"panel panel-spaced","aria-labelledby":"trace-heading",hidden:Le!=="troubleshoot",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h2",{id:"trace-heading",children:"Packet trace"}),e("p",{class:"panel-subtitle",children:"Trace packets through a selected VPP graph node"})]})}),e(ba,{active:Le==="troubleshoot"})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"capture-heading",hidden:Le!=="troubleshoot",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h2",{id:"capture-heading",children:"Packet capture"}),e("p",{class:"panel-subtitle",children:"Capture VPP traffic and download pcap files"})]})}),e(ma,{})]}),ne?e(pa,{networks:t,onClose:()=>x(!1),onSaved:i=>{x(!1),l(D=>[...D,i].sort((j,re)=>j.id-re.id)),H(""),oe(`Created pipe ${i.network_a_name} \u2194 ${i.network_b_name}`)}}):null,de?e(ua,{network:pe,onClose:()=>{ie(!1),ee(null),Q("/networks",!0)},onSaved:i=>{let D=!!pe;ie(!1),ee(null),Q("/networks",!0);let j=D?t.map(re=>re.id===i.id?i:re):[...t,i];Be({networks:j.sort((re,he)=>re.name.localeCompare(he.name,void 0,{sensitivity:"base"}))}),p(""),oe(D?`Updated network ${i.name}`:`Created network ${i.name}`)}}):null,ue?e(da,{networks:t,vms:h,onClose:()=>{me(!1),Q("/networks/interfaces",!0)},onSaved:i=>{me(!1),Q("/networks/interfaces",!0),Be({interfaces:[...b,i].sort((D,j)=>D.name.localeCompare(j.name,void 0,{sensitivity:"base"}))}),$(""),oe(`Created interface ${i.name}`),Yt()}}):null,B?e(_a,{name:B.name,label:B.label,hostAddresses:B.hostAddresses,onClose:()=>X(null)},`addresses-${B.name}`):null,Z?e(wa,{name:Z.name,label:Z.label,tables:K,onClose:()=>fe(null),onSaved:i=>{fe(null),oe(`Saved route tables for ${i.name}`)}},`route-tables-${Z.name}`):null,c?e(oa,{iface:c,vms:h,onClose:()=>{_(null),Q("/networks/interfaces",!0)},onSaved:i=>{_(null),Q("/networks/interfaces",!0),Be({interfaces:b.map(D=>D.id===i.id?i:D)}),$(""),oe(i.attached_vm_id?`Attached ${i.name} to ${i.attached_vm_name||"VM"}`:`Detached ${i.name}`),Yt()}},`attach-${c.id}`):null,Ie?e(aa,{acl:Ie.acl,vppReady:g,onClose:()=>{le(null),Q("/networks/acl",!0)},onSaved:i=>{le(null),Q("/networks/acl",!0),Be({acls:Ie.acl?u.map(D=>D.id===i.id?i:D):[...u,i].sort((D,j)=>D.name.localeCompare(j.name,void 0,{sensitivity:"base"}))}),U(""),oe(`${Ie.acl?"Saved":"Added"} ACL ${i.name}`),Kn(!1)}},Ie.acl?`edit-${Ie.acl.id}`:"new"):null,be?e(la,{iface:be,acls:u,vppReady:g,onClose:()=>{ve(null),Q("/networks/interfaces",!0)},onSaved:i=>{ve(null),Q("/networks/interfaces",!0),Be({interfaces:b.map(D=>D.id===i.id?i:D)}),$(""),Qt(!0),oe(`Applied ACLs to ${i.name}`)}},`acl-${be.id}`):null,et?e(ca,{iface:et,tables:K,onClose:()=>{Ke(null),Q("/networks/interfaces",!0)},onSaved:i=>{Ke(null),Q("/networks/interfaces",!0),Be({interfaces:b.map(D=>D.id===i.id?i:D)}),$(""),oe(`Updated route tables for ${i.name}`),ct(!1)}},`route-tables-${et.id}`):null,E?e(ia,{networks:t,onClose:()=>M(!1),onSaved:i=>{M(!1),r(D=>[...D,i].sort((j,re)=>j.name.localeCompare(re.name,void 0,{sensitivity:"base"}))),Y(""),oe(`Created host tap ${i.name}`)}}):null]})}function Na(t){t&&Fe(e(Aa,{}),t)}function Va(t){if(!t)return[];let n=new Set;for(let s of t.split(",")){let l=s.trim();if(!l)continue;let[a,o,f]=l.split("-"),r=Number(a);if(!Number.isInteger(r)||r<0)continue;let b=f?Number(o):r;if(!(!Number.isInteger(b)||bs-l)}function Bi(t){let n=[...new Set(t)].filter(l=>Number.isInteger(l)&&l>=0).sort((l,a)=>l-a),s=[];for(let l=0;l0?e("div",{class:"cpu-grid",role:"group","aria-label":"Allowed CPU cores",children:n.map(f=>e("button",{type:"button",class:o.has(f)?"cpu-chip selected":"cpu-chip","aria-pressed":o.has(f),disabled:a,onClick:()=>s(Hi(t,f)),children:f},f))}):null,e("input",{type:"text",value:t,disabled:a,placeholder:"e.g. 0-3,7 (empty means all CPUs)",onInput:f=>s(f.currentTarget.value)}),l?e("span",{class:"field-hint",children:l}):null]})}var Ma=1024*1024;function Da(t){return t?String(Math.round(t/Ma*10)/10):""}function xa(t){let n=Number(t);return!Number.isFinite(n)||n<=0?0:Math.round(n*Ma)}function Cn(t){return t===void 0||t===0?"":String(t)}function Sn(t){let n=Number(t);return Number.isFinite(n)&&n>0?Math.floor(n):0}function Pn({limits:t,devices:n,onChange:s,disabled:l}){let a=new Map(n.map(u=>[u.path,u])),o=new Set(t.map(u=>u.device)),f=n.filter(u=>!o.has(u.path)),r=(u,k)=>{s(t.map((h,A)=>A===u?{...h,...k}:h))},b=()=>{let u=f[0];u&&s([...t,{device:u.path}])},w=u=>{s(t.filter((k,h)=>h!==u))};return e("div",{class:"cgroup-io-editor",children:[e("div",{class:"table-wrap",children:e("table",{class:"cgroup-io-table",children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Block device"}),e("th",{scope:"col",children:"Read MB/s"}),e("th",{scope:"col",children:"Write MB/s"}),e("th",{scope:"col",children:"Read IOPS"}),e("th",{scope:"col",children:"Write IOPS"}),e("th",{scope:"col",children:"Weight"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.length===0?e("tr",{children:e("td",{colspan:7,class:"muted",children:"No per-device I/O limits. Add a block device to constrain it."})}):t.map((u,k)=>e("tr",{children:[e("td",{children:e("select",{value:u.device,disabled:l,onChange:h=>r(k,{device:h.currentTarget.value}),children:[a.has(u.device)?null:e("option",{value:u.device,children:u.device}),n.map(h=>e("option",{value:h.path,disabled:o.has(h.path)&&h.path!==u.device,children:[h.path,h.model?` \xB7 ${h.model}`:""]},h.path))]})}),e("td",{children:e("input",{type:"number",min:0,step:"any",disabled:l,value:Da(u.read_bps),placeholder:"unlimited",onInput:h=>r(k,{read_bps:xa(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:"any",disabled:l,value:Da(u.write_bps),placeholder:"unlimited",onInput:h=>r(k,{write_bps:xa(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:1,disabled:l,value:Cn(u.read_iops),placeholder:"unlimited",onInput:h=>r(k,{read_iops:Sn(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:1,disabled:l,value:Cn(u.write_iops),placeholder:"unlimited",onInput:h=>r(k,{write_iops:Sn(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,max:1e4,step:1,disabled:l,value:Cn(u.weight),placeholder:"inherit",onInput:h=>r(k,{weight:Sn(h.currentTarget.value)})})}),e("td",{children:e("button",{class:"row-action danger",type:"button",disabled:l,onClick:()=>w(k),children:"Remove"})})]},u.device))})]})}),e("div",{class:"actions",children:e("button",{class:"button ghost",type:"button",disabled:l||f.length===0,onClick:b,children:"Add block device"})})]})}var $a=1024*1024;function En(t){return t?String(Math.round(t/$a)):""}function Ra(t){let n=Number(t);return!Number.isFinite(n)||n<=0?0:Math.round(n*$a)}function Fa(t){return t?String(t):""}function Ba(t){let n=Number(t);return Number.isFinite(n)&&n>0?Math.floor(n):0}function _t(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(1)} MB`:n>=1024?`${(n/1024).toFixed(1)} KB`:`${n} B`}function qt(t){return t===void 0?"\u2014":`${_t(t)}/s`}function Oi(t){let n=_t(t.memory_current_bytes);return t.memory_max_bytes>0?`${n} / ${_t(t.memory_max_bytes)}`:`${n} / unlimited`}function Ui(t,n){return n<=0?0:Math.max(0,Math.min(100,t/n*100))}function Ha(){let[t,n]=d(null),[s,l]=d([]),[a,o]=d(!1),[f,r]=d(""),[b,w]=d(""),[u,k]=d(""),[h,A]=d(""),[I,m]=d([]),[g,P]=d([]),[L,S]=d(!0),[N,p]=d(!1),[T,H]=d(""),[G,J]=d(null),[ne,x]=d({}),[R,F]=d(""),[W,Y]=d(!1),y=te(null),ae=ze(),E=le=>{n(le),o(!!le.enabled),r(le.cpus||""),w(le.mems||""),k(En(le.memory_max_bytes)),A(Fa(le.cpu_weight)),m(Array.isArray(le.io_limits)?le.io_limits:[]),P(Array.isArray(le.classes)?le.classes:[])},M=async()=>{S(!0);try{let[le,be]=await Promise.all([V("/api/cgroup-settings"),V("/api/host/block-devices").catch(()=>[])]);E(le),l(Array.isArray(be)?be:[]),H("")}catch(le){H(le instanceof Error?le.message:String(le))}finally{S(!1)}},se=async()=>{Y(!0);try{let le=await V("/api/cgroup-usage"),be=le.sampled_at?new Date(le.sampled_at).getTime():Date.now(),ve=Array.isArray(le.nodes)?le.nodes:[],K=y.current,$e={};if(K&&Number.isFinite(be)&&be>K.at){let O=(be-K.at)/1e3;for(let _e of ve){let Ee=_e.path||"",q=K.nodes[Ee];q&&($e[Ee]={cpuCores:Math.max(0,(_e.cpu_usage_usec-q.cpu)/1e6/O),read:Math.max(0,(_e.io_read_bytes-q.read)/O),write:Math.max(0,(_e.io_write_bytes-q.write)/O)})}}y.current={at:Number.isFinite(be)?be:Date.now(),nodes:Object.fromEntries(ve.map(O=>[O.path||"",{cpu:O.cpu_usage_usec,read:O.io_read_bytes,write:O.io_write_bytes}]))},x($e),J(le),F("")}catch(le){F(le instanceof Error?le.message:String(le))}finally{Y(!1)}};z(()=>{M()},[]);let ce=ae.segments[0]==="settings";z(()=>{if(!ce)return;se();let le=window.setInterval(()=>{document.hidden||se()},2e3);return()=>window.clearInterval(le)},[ce]);let v=async le=>{if(le.preventDefault(),!N){p(!0),H("");try{let be=await V("/api/cgroup-settings",{method:"PUT",body:JSON.stringify({enabled:a,cpus:f.trim(),mems:b.trim(),memory_max_bytes:Ra(u),cpu_weight:Ba(h),io_limits:I.map(ve=>({device:ve.device,read_bps:ve.read_bps||0,write_bps:ve.write_bps||0,read_iops:ve.read_iops||0,write_iops:ve.write_iops||0,weight:ve.weight||0})),classes:g.map(ve=>({name:ve.name.trim(),cpus:(ve.cpus||"").trim(),memory_max_bytes:ve.memory_max_bytes||0,cpu_weight:ve.cpu_weight||0,io_limits:(ve.io_limits||[]).map(K=>({device:K.device,read_bps:K.read_bps||0,write_bps:K.write_bps||0,read_iops:K.read_iops||0,write_iops:K.write_iops||0,weight:K.weight||0}))}))})});E(be),se(),oe(a?"Saved cgroup resource settings":"Cgroup limits disabled")}catch(be){H(be instanceof Error?be.message:String(be))}finally{p(!1)}}},$=Array.isArray(t?.online_cpus)?t.online_cpus:[],C=!!t?.available,U=!!t?.enabled,de=t?.reason||"",ie=U?t?.configured?`Applied at ${t?.root||"/sys/fs/cgroup/rookery-cgroup"}`:"Enabled but not applied yet":"Disabled",pe=Array.isArray(G?.nodes)?G.nodes:[],ee=pe.find(le=>!le.path)||null,ue=pe.filter(le=>{let be=le.path||"";return be?!pe.some(ve=>{let K=ve.path||"";return K!==""&&K.startsWith(`${be}/`)}):!1}),me=ue.length>0?ue:ee?[ee]:[],c=me.reduce((le,be)=>le+(ne[be.path||""]?.cpuCores||0),0),_=me.reduce((le,be)=>le+(ne[be.path||""]?.read||0),0),B=me.reduce((le,be)=>le+(ne[be.path||""]?.write||0),0),X=G?.online_cpus||$.length||0,Z=X>0?c/X*100:0,fe=ee?Ui(ee.memory_current_bytes,ee.memory_max_bytes):0,Ie=G?.sampled_at?`sampled ${new Date(G.sampled_at).toLocaleTimeString()}`:"waiting for sample";return e("section",{class:"panel panel-spaced","aria-labelledby":"cgroup-settings-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"cgroup-settings-heading",children:"Cgroup v2 resources"}),e("p",{class:"panel-subtitle",children:"Host-wide ceilings inherited by every QEMU process"})]}),e("button",{class:"button ghost",type:"button",disabled:L||N,onClick:()=>{M(),se()},children:L?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:["Rookery runs QEMU below ",t?.root||"/sys/fs/cgroup/rookery-cgroup",". VMs without a cgroup class run in the shared child cgroup; each class is a cgroup node below the",e("code",{children:"rookery-cgroup"})," root, for example ",e("code",{children:"rookery-cgroup/prod/web"}),"."]}),e("div",{class:"cgroup-status",children:[e("span",{class:"status-badge","data-status":C?U?t?.configured?"running":"paused":"disabled":"error",children:C?U?t?.configured?"applied":"not applied":"disabled":"unavailable"}),e("span",{class:"field-hint",children:[ie,t?.mount?` \xB7 mount ${t.mount}`:"",$.length>0?` \xB7 ${$.length} online CPUs`:"",g.length>0?` \xB7 ${g.length} classes`:"",t?.cpu_model?` \xB7 ${t.cpu_model}`:""]})]}),de?e("div",{class:"banner",role:"alert",children:de}):null,e("section",{class:"cgroup-telemetry","aria-labelledby":"cgroup-usage-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h3",{id:"cgroup-usage-heading",children:"Live cgroup usage"}),e("p",{class:"panel-subtitle",children:["Memory, CPU, task, and I/O telemetry from the running hierarchy \xB7 ",Ie]})]}),e("button",{class:"button ghost",type:"button",disabled:W,onClick:()=>{se()},children:W?"Sampling\u2026":"Refresh usage"})]}),R?e("div",{class:"banner",role:"alert",children:R}):G?G.available?G.configured?pe.length===0?e("p",{class:"field-hint",children:G.reason||"No cgroup nodes are available yet."}):e(ge,{children:[e("div",{class:"cgroup-metrics",children:[e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"Memory"}),e("strong",{children:ee?_t(ee.memory_current_bytes):"\u2014"}),e("span",{class:"field-hint",children:ee&&ee.memory_max_bytes>0?`of ${_t(ee.memory_max_bytes)} \xB7 ${fe.toFixed(1)}%`:"unlimited"}),ee&&ee.memory_max_bytes>0?e("div",{class:"usage-bar",children:e("span",{style:{width:`${fe}%`}})}):null]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"CPU"}),e("strong",{children:[c.toFixed(2)," cores"]}),e("span",{class:"field-hint",children:X>0?`${Z.toFixed(1)}% of ${X} online CPUs`:"online CPU count unavailable"})]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"Tasks"}),e("strong",{children:ee?.pids_current??"\u2014"}),e("span",{class:"field-hint",children:ee&&ee.pids_max>0?`of ${ee.pids_max}`:"no task limit"})]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"I/O"}),e("strong",{children:qt(_)}),e("span",{class:"field-hint",children:["read \xB7 ",qt(B)," write"]})]})]}),e("div",{class:"table-wrap",children:e("table",{class:"cgroup-usage-table",children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Scope"}),e("th",{scope:"col",children:"Memory"}),e("th",{scope:"col",children:"CPU"}),e("th",{scope:"col",children:"Tasks"}),e("th",{scope:"col",children:"I/O"})]})}),e("tbody",{children:pe.map(le=>{let be=le.path||"",ve=ne[be];return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:le.name||be||"rookery-cgroup"}),be?e("span",{class:"vm-description mono",children:be}):null]}),e("td",{class:"mono",children:Oi(le)}),e("td",{class:"mono",children:ve?`${ve.cpuCores.toFixed(2)} cores`:"\u2014"}),e("td",{class:"mono",children:[le.pids_current,le.pids_max>0?` / ${le.pids_max}`:""]}),e("td",{class:"mono",children:ve?`\u2193 ${qt(ve.read)} \xB7 \u2191 ${qt(ve.write)}`:"\u2014"})]},be||"root")})})]})})]}):e("p",{class:"field-hint",children:G.reason||"Enable cgroup limits to start collecting resource usage."}):e("p",{class:"field-hint",children:"cgroup v2 is not available on this host."}):e("p",{class:"field-hint",children:"Waiting for the first cgroup sample\u2026"})]}),e("form",{class:"form-grid",onSubmit:le=>{v(le)},children:[e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:a,onChange:le=>o(le.currentTarget.checked)}),e("span",{children:"Enable cgroup v2 limits for QEMU"})]}),e("span",{class:"field-hint",children:"Saving applies the new host defaults to every running VM immediately: Rookery re-places each live QEMU process in its shared or class cgroup. New and restarted VMs are always placed under this hierarchy. When disabled, running QEMU processes are moved out immediately and the Rookery cgroups are removed; all VMs then run unconstrained, and class limits take effect again when host limits are enabled. Shrinking the host CPU set clamps running VMs to the remaining CPUs."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CPU cores"}),e(Tn,{value:f,options:$,disabled:!a,onChange:r,hint:"Click cores to pin QEMU to them. The text field accepts ranges. Empty means all online CPUs."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Total memory limit (MB)"}),e("input",{type:"number",min:0,step:128,disabled:!a,value:u,placeholder:"unlimited",onInput:le=>k(le.currentTarget.value)}),e("span",{class:"field-hint",children:"memory.max for the whole Rookery cgroup subtree. Empty means unlimited."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"CPU weight"}),e("input",{type:"number",min:0,max:1e4,step:1,disabled:!a,value:h,placeholder:"default (100)",onInput:le=>A(le.currentTarget.value)}),e("span",{class:"field-hint",children:"cpu.weight (1\u201310000). Empty uses the kernel default of 100."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Memory nodes ",e("em",{children:"optional"})]}),e("input",{type:"text",disabled:!a,value:b,placeholder:"e.g. 0-1 (empty means all)",onInput:le=>w(le.currentTarget.value)}),e("span",{class:"field-hint",children:"cpuset.mems NUMA node list."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Per-device I/O limits"}),e(Pn,{limits:I,devices:s,disabled:!a,onChange:m}),s.length===0?e("span",{class:"field-hint",children:["No whole block devices were reported by the host. Check access to ",e("code",{children:"/sys/class/block"}),"."]}):null,e("span",{class:"field-hint",children:"io.max limits read/write bandwidth (MiB/s) and IOPS. io.weight sets the proportional weight for that device (1\u201310000; requires the BFQ scheduler)."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Classes"}),e("span",{class:"field-hint",children:["Define a tree of reusable profiles below ",e("code",{children:"rookery-cgroup"}),". Class paths are slash-separated and may be up to 3 levels deep, for example ",e("code",{children:"prod"}),","," ",e("code",{children:"prod/web"}),", or ",e("code",{children:"prod/web/frontend"}),". Define the parent class first. VMs can only be assigned to leaf classes; parent classes group their children and inherit limits. Assign a class to a VM instead of entering limits per VM. Class limits are shared by every VM assigned to that class; allow enough memory for all of them."]}),e(Ki,{classes:g,devices:s,onlineCPUs:$,disabled:L||N,onChange:P})]}),T?e("p",{class:"form-error field-wide",role:"alert",children:T}):null,e("div",{class:"field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:N||L,children:N?"Saving\u2026":"Save cgroup settings"})})]})]})}function qi(t,n){let s=1;for(;;){let l=n?`${n}/class-${s}`:`class-${s}`;if(!t.some(a=>a.name===l))return l;s++}}function Oa(t){let n=t.lastIndexOf("/");return n>0?t.slice(0,n):""}function Wi(t){let n=t.lastIndexOf("/");return n>=0?t.slice(n+1):t}function zi(t,n,s){let l=s.trim();if(!l||l.includes("/"))return null;let a=Oa(n),o=a?`${a}/${l}`:l;return o===n?t:t.some(f=>f.name===o)?null:t.map(f=>f.name===n?{...f,name:o}:f.name.startsWith(n+"/")?{...f,name:o+f.name.slice(n.length)}:f)}function Gi(t){let n=[];return t.cpus&&n.push(`CPU ${t.cpus}`),t.memory_max_bytes&&n.push(`${En(t.memory_max_bytes)} MB`),t.cpu_weight&&n.push(`weight ${t.cpu_weight}`),t.io_limits?.length&&n.push(`${t.io_limits.length} I/O`),n.length?n.join(" \xB7 "):"inherits host limits"}function Ki({classes:t,devices:n,onlineCPUs:s,disabled:l,onChange:a}){let o=(h,A)=>{a(t.map(I=>I.name===h?{...I,...A}:I))},f=h=>{a(t.filter(A=>A.name!==h&&!A.name.startsWith(h+"/")))},r=h=>{a([...t,{name:qi(t,h)}])},b=new Map(t.map(h=>[h.name,h])),w=new Map,u=[];for(let h of t){let A=Oa(h.name);if(A&&b.has(A)){let I=w.get(A)||[];I.push(h),w.set(A,I)}else u.push(h)}for(let h of w.values())h.sort((A,I)=>A.name.localeCompare(I.name));u.sort((h,A)=>h.name.localeCompare(A.name));let k=h=>{let A=h.name.split("/").length,I=w.get(h.name)||[],m=Wi(h.name);return e("div",{class:"cgroup-tree-node",children:[e("div",{class:"cgroup-tree-node-head",children:[e("div",{class:"cgroup-tree-title",children:[e("input",{class:"cgroup-tree-name",type:"text",defaultValue:m,disabled:l,"aria-label":`Class name for ${h.name}`,onBlur:g=>{let P=zi(t,h.name,g.currentTarget.value);if(!P){g.currentTarget.value=m;return}a(P)}}),e("span",{class:"cgroup-tree-path",children:h.name}),e("span",{class:"field-hint",children:Gi(h)})]}),e("div",{class:"actions",children:[A<3?e("button",{class:"row-action",type:"button",disabled:l||t.length>=64,onClick:()=>r(h.name),children:"Add child"}):null,e("button",{class:"row-action danger",type:"button",disabled:l||I.length>0,title:I.length>0?"Remove child classes first.":"",onClick:()=>f(h.name),children:"Remove"})]})]}),e("details",{class:"cgroup-tree-limits",children:[e("summary",{children:"Limits"}),e("div",{class:"form-grid cgroup-class-grid",children:[e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CPU cores"}),e(Tn,{value:h.cpus||"",options:s,disabled:l,onChange:g=>o(h.name,{cpus:g}),hint:"Empty inherits the host/parent class CPU set. A non-empty set must be a subset of it."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory limit (MB)"}),e("input",{type:"number",min:0,step:128,disabled:l,value:En(h.memory_max_bytes),placeholder:"inherit",onInput:g=>o(h.name,{memory_max_bytes:Ra(g.currentTarget.value)})})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"CPU weight"}),e("input",{type:"number",min:0,max:1e4,step:1,disabled:l,value:Fa(h.cpu_weight),placeholder:"inherit",onInput:g=>o(h.name,{cpu_weight:Ba(g.currentTarget.value)})})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Per-device I/O limits"}),e(Pn,{limits:h.io_limits||[],devices:n,disabled:l,onChange:g=>o(h.name,{io_limits:g})})]})]})]}),I.length>0?e("div",{class:"cgroup-tree-children",children:I.map(k)}):null]},h.name)};return e("div",{class:"cgroup-tree",children:[e("div",{class:"cgroup-tree-root",children:[e("div",{children:[e("span",{class:"cgroup-tree-root-name",children:"rookery-cgroup"}),e("span",{class:"field-hint",children:"Root limits are the host settings above; classes nest up to 3 levels below."})]}),e("button",{class:"button ghost",type:"button",disabled:l||t.length>=64,onClick:()=>r(),children:"Add top-level class"})]}),u.length>0?u.map(k):e("p",{class:"field-hint",children:"No classes defined. VMs without a class run in the shared cgroup."})]})}function Ji(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function Xi(t){switch(t){case"success":return"running";case"failed":return"error";case"running":return"starting";case"pending":return"paused";default:return"disabled"}}function Ua({kind:t,title:n,endpoint:s,description:l,placeholder:a,spaced:o}){let[f,r]=d(null),[b,w]=d(!1),[u,k]=d(""),[h,A]=d(!0),[I,m]=d(!1),[g,P]=d(""),L=t==="rookery"?"Rookery":"VPP",S=`${t}-startup-script-heading`,N=async()=>{A(!0);try{let J=await V(s);r(J),w(!!J.enabled),k(J.script||""),P("")}catch(J){P(J instanceof Error?J.message:String(J))}finally{A(!1)}};z(()=>{N()},[]);let p=async J=>{if(J.preventDefault(),!I){if(b&&!u.trim()){P(`Enter a ${L} script before enabling it.`);return}m(!0),P("");try{let ne=await V(s,{method:"PUT",body:JSON.stringify({enabled:b,script:u})});r(ne),w(!!ne.enabled),k(ne.script||""),oe(ne.enabled?`Saved ${L} startup script${t==="rookery"?"; it will run on the next API start":"; it will run on the next VPP connection"}`:`Saved ${L} startup script settings`)}catch(ne){P(ne instanceof Error?ne.message:String(ne))}finally{m(!1)}}},T=f?.status||"disabled",H=f?.output||"",G=T==="success"?0:f?.exit_code??"\u2014";return e("section",{class:o?"panel panel-spaced":"panel","aria-labelledby":S,children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:S,children:n}),e("p",{class:"panel-subtitle",children:t==="rookery"?"Rookery server lifecycle hook":"Applied whenever Rookery connects to FD.io VPP"})]}),e("button",{class:"button ghost",type:"button",disabled:h||I,onClick:()=>{N()},children:h?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:l}),e("div",{class:"startup-script-status",children:[e("span",{class:"status-badge","data-status":Xi(T),children:T}),T==="success"||T==="failed"?e("span",{class:"field-hint",children:["exit code ",G,f?.ran_at?` \xB7 ${Ji(f.ran_at)}`:""]}):T==="running"?e("span",{class:"field-hint",children:"Running\u2026"}):b?e("span",{class:"field-hint",children:t==="rookery"?"Runs on the next API start.":"Runs on the next VPP connection."}):e("span",{class:"field-hint",children:"Disabled."})]}),e("form",{class:"form-grid",onSubmit:J=>{p(J)},children:[e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:b,onChange:J=>w(J.currentTarget.checked)}),e("span",{children:["Enable ",L," startup script"]})]})}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Script"}),e("textarea",{class:"startup-script-editor",rows:t==="rookery"?7:9,spellcheck:!1,placeholder:a,value:u,onInput:J=>k(J.currentTarget.value)}),e("span",{class:"field-hint",children:"The script runs with `/bin/sh` and the API process privileges. Only configure trusted commands."})]}),g?e("p",{class:"form-error field-wide",role:"alert",children:g}):null,e("div",{class:"field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:I||h,children:I?"Saving\u2026":`Save ${L} startup script`})})]}),H?e("details",{class:"startup-script-output",open:T==="failed",children:[e("summary",{children:"Last output"}),e("pre",{children:H})]}):null]})}function qa(){return e(ge,{children:[e(Ha,{}),e(Ua,{kind:"rookery",title:"Rookery startup script",endpoint:"/api/startup-script",description:"Runs once per API process after Rookery starts. It is empty by default. Use it for host-level setup that should happen once when the service starts.",placeholder:`# Runs once when the Rookery API starts -# Add host or setup commands here`,spaced:!0}),e(Ua,{kind:"vpp",title:"VPP startup script",endpoint:"/api/vpp-startup-script",description:"Runs every time Rookery connects to FD.io VPP, including after the connection drops or VPP restarts. It is enabled by default to assign NAT44 workers.",placeholder:`# Assign NAT44 workers -vppctl set nat workers 0`,spaced:!0})]})}function Wa(t){t&&Fe(e(qa,{}),t)}function Wt(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}var Zi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function In(t,n){if(!t)return"";let[s,l]=t.trim().split("/"),a=s.split(".").map(Number);if(a.length!==4||a.some(f=>!Number.isInteger(f)||f<0||f>255)||!l)return"";let o=(a[0]<<24>>>0)+(a[1]<<16)+(a[2]<<8)+a[3]+n>>>0;return`${o>>>24&255}.${o>>>16&255}.${o>>>8&255}.${o&255}`}function ji(t,n){if(!t)return"";let[s,l]=t.trim().split("/");if(!s||!l)return"";let a=s.split("::");if(a.length>2)return"";let o=h=>h?h.split(":"):[],f=o(a[0]),r=o(a[1]??""),b=8-f.length-r.length;if(b<0)return"";let w=[...f,...Array.from({length:b},()=>"0"),...r];if(w.some(h=>!/^[0-9a-fA-F]{1,4}$/.test(h)))return"";let u=0n;for(let h of w)u=(u<<16n)+BigInt(parseInt(h,16));u+=BigInt(n);let k=[];for(let h=0;h<8;h+=1)k.unshift((u&0xffffn).toString(16)),u>>=16n;return k.join(":")}function An(t){let n=In(t,1);return!n||!t?"":`${n}/${t.trim().split("/")[1]}`}function Yi(t,n,s,l){let a=t?.type??"dhcpv4",o=t?.config??{},f=n.find(b=>b.id===(t?.network_id??l??n[0]?.id)),r=An(f?.ipv4_cidr)||"10.0.0.1/16";return{name:t?.name??"",networkID:t?.network_id??l??n[0]?.id??0,type:a,enabled:t?t.enabled:s,gateway:o.gateway??(a==="dhcpv6"?"fd00:20::1/64":r),dns:o.dns??(a==="dhcpv6"?"2001:4860:4860::8888":"1.1.1.1"),rangeStart:o.range_start??(a==="dhcpv6"?"fd00:20::100":"10.20.0.100"),rangeEnd:o.range_end??(a==="dhcpv6"?"fd00:20::200":"10.20.0.200"),leaseTime:o.lease_time??"12h",outsideInterface:o.outside_interface??"",outsideAddress:o.outside_address??"",outsideGateway:o.outside_gateway??"",nat44Pool:o.nat44_pool??"",dnatRules:Array.isArray(o.dnat_rules)?o.dnat_rules.map(b=>({...b})):[],staticAssignments:Array.isArray(o.static_assignments)?o.static_assignments.map(b=>({...b})):[],dhcpOptions:Array.isArray(o.dhcp_options)?o.dhcp_options.map(b=>({...b})):[],dnsRecords:Array.isArray(o.dns_records)?o.dns_records.map(b=>({...b})):[],dnsForward:o.dns_forward??!1,dnsForwarders:Array.isArray(o.dns_forwarders)?o.dns_forwarders.join(", "):t?.type==="dns"?"":"1.1.1.1, 8.8.8.8",dnsVNFID:o.dns_vnf_id??0,dnsDomain:o.dns_domain??"",dnsTTL:String(o.dns_ttl??300),transport:a==="bgp"?o.transport==="tap"?"tap":"memif":o.transport==="shared"||o.transport==="dedicated"||o.transport==="tap"?o.transport:"inherit",localAS:String(o.local_as??64512),routerID:o.router_id??"10.20.0.1",peerAS:String(o.peer_as??64513),peerIP:o.peer_ip??"169.254.0.2",localIP:o.local_ip??"169.254.0.1/30",peerGateway:o.peer_gateway??"",nextHop:o.next_hop??"",multihopTTL:String(o.multihop_ttl??1),password:o.password??"",passive:o.passive??!1,targetTableID:String(o.target_table_id??0),advertisePrefixes:Array.isArray(o.advertise_prefixes)?o.advertise_prefixes.join(` -`):""}}function Qi(t,n){return n==="dns"?{...t,type:n,dnsRecords:[],dnsForward:!0,dnsForwarders:"1.1.1.1, 8.8.8.8"}:n==="bgp"?{...t,type:n,transport:"memif",localAS:"64512",routerID:"10.20.0.1",peerAS:"64513",peerIP:"169.254.0.2",localIP:"169.254.0.1/30",peerGateway:"",nextHop:"",multihopTTL:"1",password:"",passive:!1,targetTableID:"0",advertisePrefixes:""}:n==="dhcpv6"?{...t,type:n,transport:"tap",gateway:"fd00:20::1/64",dns:"2001:4860:4860::8888",rangeStart:"fd00:20::100",rangeEnd:"fd00:20::200",leaseTime:"12h",dnsVNFID:0,dnsDomain:"",dnsTTL:"300"}:n==="nat44"?{...t,type:n,gateway:"10.20.0.1/24",outsideAddress:"",outsideGateway:"",nat44Pool:""}:{...t,type:n,transport:"inherit",gateway:"10.20.0.1/24",dns:"1.1.1.1",rangeStart:"10.20.0.100",rangeEnd:"10.20.0.200",leaseTime:"12h",dnsVNFID:0,dnsDomain:"",dnsTTL:"300"}}function er(t,n){let s=t.find(l=>l.name===n);return s?.default_route?"This NIC carries the host default route and cannot be used for NAT.":s?.physical===!1?"Select a wired physical NIC; virtual and VPP-managed interfaces are not valid outside uplinks.":s?.wireless?"Wireless NICs are not supported as VPP NAT44 outside uplinks; use a wired NIC.":s?`VPP will take over ${s.name} and flush its host addresses.`:"The selected NIC will be taken over by VPP; its host addresses are flushed."}function tr(t){return t==="dns"?"Local DNS server on the network service address (.2) over IPv4 and IPv6. Serve A/AAAA/CNAME/TXT/PTR records and optionally forward other names upstream.":t==="bgp"?"Embedded IPv4/IPv6 BGP speaker. Select memif for userspace/gVisor or tap for kernel TCP with TCP MD5 support.":t==="nat44"?"IPv4 NAT44 gateway. A dedicated network BVI owns the gateway; the outside NIC is taken over by VPP.":t==="dhcpv6"?"IPv6 address pool with stateful IA_NA leases on a VPP tap (shared memif is DHCPv4-only for now).":"IPv4 address pool served on the network-shared memif by default (VNF address .2)."}var nr=[{code:3,label:"Router",format:"ipv4",placeholder:"10.20.0.1",hint:"Overrides the gateway router option."},{code:6,label:"DNS servers",format:"ipv4",placeholder:"10.20.0.2, 1.1.1.1",hint:"Overrides the DNS field."},{code:15,label:"Domain name",format:"string",placeholder:"lab.test",hint:"This is the domain name, not DNS server addresses. Use code 6 for DNS servers."},{code:42,label:"NTP servers",format:"ipv4",placeholder:"10.20.0.1, 10.20.0.2"},{code:44,label:"WINS servers",format:"ipv4",placeholder:"10.20.0.10"},{code:66,label:"TFTP server name",format:"string",placeholder:"tftp.lab.test"},{code:67,label:"Bootfile name",format:"string",placeholder:"pxelinux.0"},{code:119,label:"Domain search list",format:"domain-list",placeholder:"lab.test, apps.lab.test"},{code:252,label:"WPAD URL",format:"string",placeholder:"http://wpad.lab.test/wpad.dat"}],sr=[{code:21,label:"SIP domain names",format:"domain-list",placeholder:"sip.lab.test"},{code:22,label:"SIP servers",format:"ipv6",placeholder:"fd00:20::10"},{code:23,label:"DNS recursive name servers",format:"ipv6",placeholder:"fd00:20::2",hint:"Overrides the DNS field."},{code:24,label:"Domain search list",format:"domain-list",placeholder:"lab.test, apps.lab.test"},{code:31,label:"SNTP servers",format:"ipv6",placeholder:"fd00:20::10"},{code:59,label:"Bootfile URL",format:"string",placeholder:"http://[fd00:20::1]/boot"},{code:60,label:"Bootfile parameters",format:"string",placeholder:"console=ttyS0"}];function za(t){return t==="dhcpv6"?sr:nr}function Ln(t,n){return za(t).find(s=>s.code===n)}function Ga({vnf:t,networks:n,vnfs:s,vppReady:l,initialNetworkID:a,onClose:o,onSaved:f}){let[r,b]=d(()=>Yi(t,n,l,a)),[w,u]=d([]),[k,h]=d(!1),[A,I]=d(!1),[m,g]=d(""),[P,L]=ke("vnf",t?.id),S=te(null),N=te(null),p=t!==null,T=r.type==="nat44",H=r.type==="bgp",G=r.type==="dns",J=n.find(c=>c.id===r.networkID),ne=In(J?.ipv4_cidr,2),x=ji(J?.ipv6_cidr,2),R=s.filter(c=>c.type==="dns"&&c.network_id===r.networkID),F=(()=>{let c=J?.ipv4_cidr?.trim(),_=In(c,2),B=c?.split("/")[1];return _&&B?`${_}/${B}`:""})(),W=r.gateway||"the gateway address";z(()=>{S.current?.focus(),document.title=`${t?"Edit":"Add"} VNF \xB7 rookery`},[t]),z(()=>{if(!T)return;let c=!1;return h(!0),V("/api/host/interfaces").then(_=>{c||u(Array.isArray(_)?_:[])}).catch(()=>{c||u([])}).finally(()=>{c||h(!1)}),()=>{c=!0}},[T]);let Y=(c,_)=>{b(B=>({...B,[c]:_}))},y=()=>{A||o()},ae=async c=>{if(c.preventDefault(),A||!N.current?.reportValidity())return;if(g(""),!H&&!G&&!r.gateway.trim()){g("Gateway CIDR is required.");return}if(!H&&!T&&!G&&(!r.rangeStart.trim()||!r.rangeEnd.trim())){g("Range start and end are required.");return}if(T&&!r.outsideInterface){g("Select an outside network interface.");return}if(T&&r.enabled&&!window.confirm(`Take over ${r.outsideInterface} for NAT44? +This removes the VPP vhost-user interface and socket.`))try{await M(`/api/interfaces/${i.id}`,{method:"DELETE"}),qe({interfaces:g.filter(Y=>Y.id!==i.id)}),S(""),le(`Deleted interface ${i.name}`)}catch(Y){S(`Could not delete ${i.name}: ${Y instanceof Error?Y.message:String(Y)}`)}},Eo=async i=>{let D=i.attached_vm_id?h.find(Y=>Y.id===i.attached_vm_id):null;if(D&&(D.status==="running"||D.status==="starting")){le("Stop the VM before detaching its interface.",!0);return}if(window.confirm(`Detach interface "${i.name}" from its VM?`))try{let Y=await M(`/api/interfaces/${i.id}`,{method:"PUT",body:JSON.stringify({attached_vm_id:null})});qe({interfaces:g.map(de=>de.id===Y.id?Y:de)}),S(""),le(`Detached ${i.name}`)}catch(Y){S(`Could not detach ${i.name}: ${Y instanceof Error?Y.message:String(Y)}`)}},Lo=async i=>{if(Bt(i,g).length>0){le(`Remove ACL ${i.name} from its interfaces first.`,!0);return}if(window.confirm(`Delete ACL "${i.name}"?`))try{await M(`/api/acls/${i.id}`,{method:"DELETE"}),qe({acls:d.filter(D=>D.id!==i.id)}),V(""),le(`Deleted ACL ${i.name}`)}catch(D){V(`Could not delete ${i.name}: ${D instanceof Error?D.message:String(D)}`),le(`Could not delete ACL ${i.name}: ${D instanceof Error?D.message:String(D)}`,!0)}},Io=async i=>{if(!i.is_default&&window.confirm(`Delete route table "${i.name}" (${i.table_id}/${i.ipv6?"IPv6":"IPv4"})?`))try{await M(`/api/route-tables/${i.id}`,{method:"DELETE"}),await dt(),le(`Deleted route table ${i.name}`)}catch(D){let Y=D instanceof Error?D.message:String(D);we(`Could not delete ${i.name}: ${Y}`),le(`Could not delete route table ${i.name}: ${Y}`,!0)}},No=i=>{window.dispatchEvent(new CustomEvent("rookery:open-tab",{detail:"vnfs"})),window.dispatchEvent(new CustomEvent("rookery:add-vnf",{detail:{networkId:i.id}}))},Do=g.filter(i=>i.attached_vm_id||i.attached_lightvm_id).length,Ao=d.filter(i=>Bt(i,g).length>0).length,xo=t.length===0?E?"Loading networks\u2026":"No Rookery Networks registered":`${t.length} network${t.length===1?"":"s"} \xB7 ${g.length} interface${g.length===1?"":"s"} \xB7 ${T.length} VNF${T.length===1?"":"s"}`,Mo=a.length===0?E?"Loading host interfaces\u2026":"No host interfaces found":`${a.length} host interface${a.length===1?"":"s"} \xB7 ${a.filter(i=>i.in_vpp).length} in VPP`,Vo=f.length===0?E?"Loading host taps\u2026":"No network host taps":`${f.length} host tap${f.length===1?"":"s"} \xB7 ${new Set(f.map(i=>i.network_id)).size} network${new Set(f.map(i=>i.network_id)).size===1?"":"s"}`,$o=g.length===0?E?"Loading interfaces\u2026":"No virtual interfaces registered":`${g.length} interface${g.length===1?"":"s"} \xB7 ${Do} attached`,Ro=d.length===0?E?"Loading ACLs\u2026":"No ACLs defined":`${d.length} ACL${d.length===1?"":"s"} \xB7 ${Ao} assigned`,Yn=i=>!i||!i.acl_indexes||i.acl_indexes.length===0?"no ACL":`${et(i.matched)} matched \xB7 ${et(i.denied)} denied \xB7 ${et(i.permitted)} permitted`,Fo=i=>{let D=i.acl_index??0,Y=i.rule_index??0,be=d.find(We=>We.vpp_index===D)?.rules[Y];return be?vn(be):`ACL ${D} rule ${Y}`},Qn=(i,D)=>{let Y=D?.rules||[];return e("div",{class:"acl-rule-counter-group",children:[e("span",{class:"acl-counter-direction",children:i}),!D?.acl_indexes||D.acl_indexes.length===0?e("span",{class:"acl-rule-counter muted",children:"No ACL applied"}):Y.length===0?e("span",{class:"acl-rule-counter muted",children:"No explicit rule matches yet"}):Y.map((de,be)=>e("span",{class:"acl-rule-counter",children:[e("span",{class:"acl-rule-counter-text",children:Fo(de)}),e("span",{class:"acl-rule-counter-value",children:[et(de.matched)," pkts \xB7 ",de.action||"\u2014"]})]},`${de.acl_index??0}-${de.rule_index??0}-${be}`))]})};return e(ye,{children:[e("div",{class:"subtabs",role:"tablist","aria-label":"Network sections",children:Aa.map(i=>e("button",{class:"subtab",type:"button",role:"tab","aria-selected":Ie===i.id,onClick:()=>{te(`/networks/${i.id}`),i.id==="interfaces"&&en()},children:i.label},i.id))}),e("section",{class:"panel","aria-labelledby":"networks-heading",hidden:Ie!=="networks",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"networks-heading",children:"Network"}),e("p",{class:"panel-subtitle",children:xo})]}),e("button",{class:"button ghost",type:"button",disabled:E,onClick:()=>{lt()},children:E?"Refreshing\u2026":"Refresh"})]}),A?e("div",{class:"banner",role:"alert",children:A}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Bridge domain"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",children:"VNFs"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(i=>{let D=g.filter(de=>de.network_id===i.id).length,Y=T.filter(de=>de.network_id===i.id);return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),e("span",{class:"vm-description mono",children:[i.ipv4_cidr||"no IPv4 CIDR"," \xB7 ",i.ipv6_cidr||"no IPv6 CIDR"]}),i.bvi_host_name?e("span",{class:"vm-description",children:[i.bvi_host_name,i.ipv4_gateway?` \xB7 ${i.ipv4_gateway}`:""]}):null,e(Ve,{entityType:"network",entityID:i.id,name:i.name,labels:Hn.labels[String(i.id)],onSaved:de=>Hn.setEntityLabels(String(i.id),de)})]}),e("td",{class:"mono",children:["bd ",i.bridge_domain_id]}),e("td",{class:D?"mono":"mono muted",children:String(D)}),e("td",{class:Y.length?"mono":"mono muted",children:Y.length?Y.map(de=>`${de.name} (${de.type})`).join(", "):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>te(`/networks/${i.id}/edit`),children:"Edit CIDRs"}),e("button",{class:"row-action",type:"button",onClick:()=>No(i),children:"Add VNF"}),e("button",{class:"row-action danger",type:"button",disabled:D>0||!y,title:D>0?"Delete the virtual interfaces first.":y?"":"VPP must be ready to delete a bridge domain.",onClick:()=>{So(i)},children:"Delete"})]})})]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2B22"}),e("h3",{children:"No Networks yet"}),e("p",{children:"Create a VPP bridge domain to connect virtual interfaces and VMs."}),e("button",{class:"button primary",type:"button",disabled:!y,onClick:()=>te("/networks/new"),children:"Create a network"})]})})]}),e("section",{class:"panel","aria-labelledby":"pipes-heading",hidden:Ie!=="pipes",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"pipes-heading",children:"Bridge-domain pipes"}),e("p",{class:"panel-subtitle",children:"Connect two Rookery Networks with a VPP pipe pair."})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:E,onClick:()=>{lt()},children:E?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!y||t.length<2,title:t.length<2?"Create at least two networks first":y?"":"VPP must be ready",onClick:()=>x(!0),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add pipe"]})]})]}),e("p",{class:"field-hint",children:"A pipe creates two VPP interfaces joined back-to-back. One end is placed in each bridge domain, providing an L2 bridge between the selected networks."}),t.length<2?e("p",{class:"field-hint",children:"Create at least two networks before adding a pipe."}):null,_?e("div",{class:"banner",role:"alert",children:_}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Network A"}),e("th",{scope:"col",children:"Network B"}),e("th",{scope:"col",children:"VPP instance"}),e("th",{scope:"col",children:"Endpoints"}),e("th",{scope:"col",class:"hide-sm",children:"Created"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:s.map(i=>e("tr",{children:[e("td",{children:e("span",{class:"vm-name",children:i.network_a_name})}),e("td",{children:[e("span",{class:"vm-name",children:i.network_b_name}),e(Ve,{entityType:"network_pipe",entityID:i.id,name:`${i.network_a_name} \u2194 ${i.network_b_name}`,labels:On.labels[String(i.id)],onSaved:D=>On.setEntityLabels(String(i.id),D)})]}),e("td",{class:"mono",children:["pipe",i.instance]}),e("td",{class:"mono",children:[e("span",{children:[Ot(i,0)," (",i.endpoint_a_sw_if_index||"\u2014",")"]}),e("span",{children:" \u2194 "}),e("span",{children:[Ot(i,1)," (",i.endpoint_b_sw_if_index||"\u2014",")"]})]}),e("td",{class:"updated hide-sm",children:pt(i.created_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:!y||!i.endpoint_a_sw_if_index,onClick:()=>Q({name:Ot(i,0),label:`pipe endpoint A \xB7 ${i.network_a_name}`}),children:"A addresses"}),e("button",{class:"button ghost",type:"button",disabled:!y||!i.endpoint_b_sw_if_index,onClick:()=>Q({name:Ot(i,1),label:`pipe endpoint B \xB7 ${i.network_b_name}`}),children:"B addresses"}),e("button",{class:"row-action danger",type:"button",disabled:H===String(i.id)||!y,onClick:()=>{To(i)},children:H===String(i.id)?"Deleting\u2026":"Delete"})]})})]},i.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2194"}),e("h3",{children:"No bridge-domain pipes"}),e("p",{children:"Connect two Rookery Networks to pass L2 traffic between their bridge domains."}),e("button",{class:"button primary",type:"button",disabled:!y||t.length<2,onClick:()=>x(!0),children:"Add pipe"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"hosts-heading",hidden:Ie!=="hosts",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"hosts-heading",children:"Host interfaces"}),e("p",{class:"panel-subtitle",children:Mo})]}),e("button",{class:"button ghost",type:"button",disabled:E,onClick:()=>{lt()},children:E?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:["Physical NICs and external interfaces visible to the server; Rookery/VPP taps and host interfaces are hidden. \u201CIn VPP\u201D means a matching",e("code",{children:"host-"})," interface currently exists in VPP; the VPP state is shown as unknown when VPP cannot be queried. Attach takes a physical NIC into VPP (flushing its host addresses); persisted attachments are recreated after a VPP restart."]}),F?e("div",{class:"banner",role:"alert",children:F}):null,e("div",{class:"table-wrap",children:a.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Host / VPP addresses"}),e("th",{scope:"col",children:"Link"}),e("th",{scope:"col",children:"VPP"}),e("th",{scope:"col",children:"Default route"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:a.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:i.name}),e(Ve,{entityType:"host_interface",entityID:i.name,name:i.name,labels:Un.labels[i.name],onSaved:D=>Un.setEntityLabels(i.name,D)})]}),e("td",{class:"mono",children:e("div",{class:"address-stack",children:[e("span",{children:[e("span",{class:"address-side",children:"Host"}),i.addresses&&i.addresses.length>0?i.addresses.join(", "):"\u2014"]}),i.in_vpp?e("span",{children:[e("span",{class:"address-side",children:"VPP"}),i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"]}):null]})}),e("td",{children:e("span",{class:"status-badge","data-status":i.up?"running":"disabled",children:i.up?"up":"down"})}),e("td",{children:i.vpp_checked?i.in_vpp?e(ye,{children:[e("span",{class:"status-badge","data-status":i.vpp_up?"running":"degraded",children:"in VPP"}),e("span",{class:"vm-description mono",children:[i.vpp_name||`host-${i.name}`,typeof i.vpp_sw_if_index=="number"?` \xB7 sw_if_index ${i.vpp_sw_if_index}`:"",i.managed?" \xB7 persisted":""]})]}):e("span",{class:"status-badge","data-status":"disabled",children:"not in VPP"}):e("span",{class:"status-badge","data-status":"disabled",children:"unknown"})}),e("td",{class:"mono",children:i.default_route?"yes":"\u2014"}),e("td",{children:i.wireless?"wireless":i.physical?"physical":"virtual"}),e("td",{children:e("div",{class:"actions",children:i.in_vpp?e(ye,{children:[i.managed?e(ye,{children:[e("button",{class:"button ghost",type:"button",disabled:!y,onClick:()=>Q({name:i.vpp_name||`host-${i.name}`,label:"host interface",hostAddresses:i.addresses||[]}),children:"Addresses"}),e("button",{class:"button ghost",type:"button",disabled:!y,onClick:()=>q({name:i.vpp_name||`host-${i.name}`,label:"Host interface"}),children:"Route tables"})]}):null,e("button",{class:"button ghost",type:"button",disabled:X===i.name||!y,onClick:()=>{_o(i)},children:X===i.name?"Detaching\u2026":"Detach"})]}):e("button",{class:"button ghost",type:"button",disabled:X===i.name||!y||!i.physical||i.wireless||i.default_route,title:i.default_route?"The host default-route interface cannot be attached":i.wireless?"Wireless interfaces cannot be attached":i.physical?"Take this NIC into VPP":"Only physical interfaces can be attached",onClick:()=>{yo(i)},children:X===i.name?"Attaching\u2026":"Attach to VPP"})})})]},i.name))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No host interfaces found"}),e("p",{children:"Linux network interfaces will appear here when available."})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"hosttaps-heading",hidden:Ie!=="hosttaps",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"hosttaps-heading",children:"Host taps"}),e("p",{class:"panel-subtitle",children:Vo})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:E,onClick:()=>{lt()},children:E?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!y||t.length===0,onClick:()=>N(!0),children:"Add host tap"})]})]}),e("p",{class:"field-hint",children:"Host taps bridge a Rookery Network into a Linux tap interface on the server. Assign a host IPv4 address here (or use DHCP on the network) and the host can reach VMs and other machines on that network directly. Host taps are persisted and recreated after an API or VPP restart."}),z?e("div",{class:"banner",role:"alert",children:z}):null,e("div",{class:"table-wrap",children:f.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Interface"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"Host / VPP address"}),e("th",{scope:"col",children:"VPP index"}),e("th",{scope:"col",children:"Created"}),e("th",{scope:"col",children:"Actions"})]})}),e("tbody",{children:f.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:i.name}),e(Ve,{entityType:"host_tap",entityID:i.id,name:i.name,labels:qn.labels[String(i.id)],onSaved:D=>qn.setEntityLabels(String(i.id),D)})]}),e("td",{children:[e("span",{class:"vm-name",children:i.network_name}),e("span",{class:"vm-description mono",children:["#",i.network_id]})]}),e("td",{class:"mono",children:e("div",{class:"address-stack",children:[e("span",{children:[e("span",{class:"address-side",children:"Host"}),i.ipv4_address||"\u2014"]}),e("span",{children:[e("span",{class:"address-side",children:"VPP"}),i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"]})]})}),e("td",{class:"mono",children:i.sw_if_index||"\u2014"}),e("td",{children:pt(i.created_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:!y,onClick:()=>Q({name:i.name,label:"host tap",hostAddresses:i.ipv4_address?[i.ipv4_address]:[]}),children:"Addresses"}),e("button",{class:"button ghost",type:"button",disabled:!y||!i.sw_if_index,onClick:()=>q({name:i.name,label:"Host tap"}),children:"Route tables"}),e("button",{class:"button ghost",type:"button",disabled:se===i.name||!y,onClick:()=>{vo(i)},children:se===i.name?"Deleting\u2026":"Delete"})]})})]},i.id))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No host taps"}),e("p",{children:"Connect a Rookery Network to the Linux host to reach its machines."}),e("button",{class:"button primary",type:"button",disabled:!y||t.length===0,onClick:()=>N(!0),children:"Add host tap"})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"interfaces-heading",hidden:Ie!=="interfaces",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"interfaces-heading",children:"Rookery Virtual Interfaces"}),e("p",{class:"panel-subtitle",children:[$o,y?e("span",{class:"live-indicator","data-live":kt?"false":"true",children:kt?"counters unavailable":"live counters"}):null]})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:()=>{en(!0)},children:"Refresh counters"}),e("button",{class:"button ghost",type:"button",disabled:!y,title:y?"":"VPP must be ready to reset interface counters.",onClick:()=>{Co()},children:"Reset counters"}),e("button",{class:"button primary",type:"button",disabled:t.length===0||!y,title:t.length===0?"Create a Rookery Network first":y?"":"Interface creation is blocked until FD.io VPP is ready",onClick:()=>te("/interfaces/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add interface"]})]})]}),$?e("div",{class:"banner",role:"alert",children:$}):null,kt?e("p",{class:"field-hint",children:["Live counters unavailable: ",kt]}):null,e("div",{class:"table-wrap",children:g.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"MAC"}),e("th",{scope:"col",children:"VPP addresses"}),e("th",{scope:"col",children:"Attached to"}),e("th",{scope:"col",children:"TX/RX"}),e("th",{scope:"col",children:"Speed"}),e("th",{scope:"col",children:"ACLs"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:g.map(i=>{let D=i.attached_vm_id?h.find(mt=>mt.id===i.attached_vm_id):null,Y=!!i.attached_vm_id,de=!!(i.attached_vm_id||i.attached_lightvm_id),be=fo[i.name],We=ho[i.name],ze=!!(D&&(D.status==="running"||D.status==="starting")),Ge=[],tt=i.ingress_acls?.length?i.ingress_acls:i.ingress_acl_name?[i.ingress_acl_name]:[],ut=i.egress_acls?.length?i.egress_acls:i.egress_acl_name?[i.egress_acl_name]:[];tt.length>0&&Ge.push(`from: ${tt.join(" \u2192 ")}`),ut.length>0&&Ge.push(`to: ${ut.join(" \u2192 ")}`);let Ze=uo.find(mt=>mt.interface===i.name),st=Re===i.id;return e(ye,{children:[e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.socket_path?e("span",{class:"vm-description",title:i.socket_path,children:i.socket_path}):null,e("span",{class:"vm-description",children:["FIB v4: ",i.ipv4_table_name||"default"," \xB7 v6:"," ",i.ipv6_table_name||"default"]}),e(Ve,{entityType:"interface",entityID:i.id,name:i.name,labels:Wn.labels[String(i.id)],onSaved:mt=>Wn.setEntityLabels(String(i.id),mt)})]}),e("td",{class:"mono",children:i.network_name||"\u2014"}),e("td",{class:"mono",children:i.mac||"\u2014"}),e("td",{class:"mono",children:i.vpp_addresses&&i.vpp_addresses.length>0?i.vpp_addresses.join(", "):"\u2014"}),e("td",{class:de?"mono":"mono muted",children:D?D.name:i.attached_vm_name||(i.attached_lightvm_name?`${i.attached_lightvm_name} (LightVM)`:"unattached")}),e("td",{class:be?"mono traffic-cell":"mono muted traffic-cell",children:be?e(ye,{children:[e("span",{class:"traffic-line",children:["\u2193 ",Ut(be.rx_bytes??0)]}),e("span",{class:"traffic-line",children:["\u2191 ",Ut(be.tx_bytes??0)]})]}):"\u2014"}),e("td",{class:We?"mono traffic-cell":"mono muted traffic-cell",children:We?e(ye,{children:[e("span",{class:"traffic-line",children:["\u2193 ",Ut(We.rx),"/s"]}),e("span",{class:"traffic-line",children:["\u2191 ",Ut(We.tx),"/s"]})]}):"\u2014"}),e("td",{class:Ge.length?"mono":"mono muted",children:Ge.length?e("div",{class:"interface-acl-summary",children:[e("span",{class:"acl-assignment",children:Ge.join(" \xB7 ")}),Ze?e("span",{class:"acl-inline-counter",children:["from ",et(Ze.ingress?.matched)," \xB7 to"," ",et(Ze.egress?.matched)]}):Rn?Jt?e("span",{class:"acl-inline-counter muted",children:"counters unavailable"}):null:e("span",{class:"acl-inline-counter muted",children:"loading counters\u2026"}),e("button",{class:"row-action",type:"button",onClick:()=>Ae(st?null:i.id),children:st?"Hide details":"ACL details"})]}):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!y,title:y?"":"VPP must be ready to set interface addresses.",onClick:()=>Q({name:i.name,label:"vhost-user"}),children:"Addresses"}),e("button",{class:"row-action",type:"button",disabled:!!i.attached_lightvm_id,title:i.attached_lightvm_id?"Remove the interface from its LightVM first.":"",onClick:()=>te(`/interfaces/${i.id}/attach`),children:"Attach"}),Y?e("button",{class:"row-action",type:"button",disabled:ze,title:ze?"Stop the VM before detaching its interface.":"",onClick:()=>{Eo(i)},children:"Detach"}):null,!Y&&i.attached_lightvm_id?e("button",{class:"row-action",type:"button",onClick:()=>te(`/lightvm/instances/${i.attached_lightvm_id}/edit`),children:"Edit LightVM"}):null,e("button",{class:"row-action",type:"button",disabled:!y,title:y?"":"VPP must be ready to apply interface ACLs.",onClick:()=>te(`/interfaces/${i.id}/acls`),children:"ACLs"}),e("button",{class:"row-action",type:"button",disabled:!y,title:y?"":"VPP must be ready to change interface FIB tables.",onClick:()=>te(`/interfaces/${i.id}/route-tables`),children:"Route tables"}),e("button",{class:"row-action danger",type:"button",disabled:de||!y,title:de?"Detach the interface from its VM or LightVM first.":y?"":"VPP must be ready to delete a vhost-user interface.",onClick:()=>{Po(i)},children:"Delete"})]})})]}),st&&Ge.length>0?e("tr",{class:"acl-details-row",children:e("td",{colSpan:10,children:e("div",{class:"interface-acl-details",children:[e("div",{class:"interface-acl-details-head",children:[e("strong",{children:["ACL details for ",i.name]}),e("button",{class:"button ghost",type:"button",onClick:()=>te(`/interfaces/${i.id}/acls`),children:"Edit ACLs"})]}),Ze?e(ye,{children:[e("div",{class:"acl-details-summary",children:[e("span",{children:[e("b",{children:"From interface"})," ",Yn(Ze.ingress)]}),e("span",{children:[e("b",{children:"To interface"})," ",Yn(Ze.egress)]})]}),e("div",{class:"acl-rule-counter-list",children:[Qn("From interface rules",Ze.ingress),Qn("To interface rules",Ze.egress)]})]}):Jt?e("p",{class:"field-hint",children:["ACL counters unavailable: ",Jt]}):Rn?e("p",{class:"field-hint",children:"No ACL counter entry for this interface yet. Generate traffic or refresh the interface list."}):e("p",{class:"field-hint",children:"Loading ACL counters\u2026"}),e("p",{class:"field-hint",children:"Counters show explicit ACL rule matches; implicit-deny packets are not included."})]})})}):null]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u21C4"}),e("h3",{children:"No virtual interfaces yet"}),e("p",{children:"Add a VPP vhost-user interface to a Rookery Network, then attach it to a VM."})]})})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"acls-heading",hidden:Ie!=="acl",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"acls-heading",children:"Network ACLs"}),e("p",{class:"panel-subtitle",children:Ro})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",disabled:E,onClick:()=>{lt()},children:E?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",disabled:!y,title:y?"":"ACL changes are blocked until FD.io VPP is ready.",onClick:()=>te("/acls/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add ACL"]})]})]}),v?e("div",{class:"banner",role:"alert",children:v}):null,e("div",{class:"table-wrap",children:d.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Title"}),e("th",{scope:"col",children:"Rules"}),e("th",{scope:"col",children:"Assigned to"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:d.map(i=>{let D=Bt(i,g);return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.description?e("span",{class:"vm-description",title:i.description,children:i.description}):null,e(Ve,{entityType:"acl",entityID:i.id,name:i.name,labels:Gn.labels[String(i.id)],onSaved:Y=>Gn.setEntityLabels(String(i.id),Y)})]}),e("td",{children:i.title||"\u2014"}),e("td",{class:"mono",children:[i.rules?.length||0," rule",i.rules?.length===1?"":"s"]}),e("td",{class:D.length?"mono":"mono muted",children:D.length?D.map(Y=>Y.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:pt(i.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",disabled:!y,title:y?"":"VPP must be ready to edit ACLs.",onClick:()=>te(`/acls/${i.id}/edit`),children:"Edit / Preview"}),e("button",{class:"row-action danger",type:"button",disabled:D.length>0,title:D.length>0?"Remove this ACL from its interfaces first.":"",onClick:()=>{Lo(i)},children:"Delete"})]})})]},i.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25A4"}),e("h3",{children:"No ACLs yet"}),e("p",{children:"Define ordered ingress/egress rules, then assign an ACL to a virtual interface."}),e("button",{class:"button primary",type:"button",disabled:!y,onClick:()=>te("/acls/new"),children:"Add an ACL"})]})})]}),e("section",{class:"panel panel-spaced","aria-label":"Route tables",hidden:Ie!=="routes",children:De?e(_a,{table:De.table,routeID:De.routeID,interfaces:g,bviInterfaces:t.filter(i=>!!i.bvi_host_name).map(i=>i.bvi_host_name||"").filter(Boolean),hostInterfaces:a,hostTaps:f,tables:j,onBack:()=>{He(null),te(`/route-tables/${De.table.id}?ipv6=${De.table.ipv6}`,!0)},onSaved:()=>{let i=De.table;He(null),te(`/route-tables/${i.id}?ipv6=${i.ipv6}`,!0),le(De.routeID?`Saved route ${i.name}`:`Added route to ${i.name}`),dt(!1)}},`route-form-${De.table.id}-${De.routeID??"new"}`):ke?e(ka,{table:ke,onBack:()=>{Se(null),te("/networks/routes",!0)},onChanged:()=>{dt(!1)}},`route-page-${ke.ipv6?"v6":"v4"}-${ke.id}`):Te?e(wa,{tables:j,onCancel:()=>{W(!1),te("/networks/routes",!0)},onSaved:i=>{W(!1),te("/networks/routes",!0),le(`Created route table ${i.name}`),dt()}}):e(ye,{children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"routes-heading",children:"Route tables"}),e("p",{class:"panel-subtitle",children:"IP FIB / VRF definitions attachable to interfaces. Table 0 is the default VRF."})]}),e("button",{class:"button primary",type:"button",disabled:!y,onClick:()=>te("/route-tables/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add route table"]})]}),O?e("div",{class:"banner",role:"alert",children:O}):null,e("div",{class:"table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Family"}),e("th",{scope:"col",children:"Table ID"}),e("th",{scope:"col",children:"Interfaces"}),e("th",{scope:"col",children:"Routes"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:j.map(i=>e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:i.name}),i.is_default?e("span",{class:"vm-description",children:"default VRF"}):null,i.network_id?e("span",{class:"vm-description",children:["network VRF \xB7 network ",i.network_id]}):null,i.is_default?null:e(Ve,{entityType:"route_table",entityID:i.id,name:i.name,labels:zn.labels[String(i.id)],onSaved:D=>zn.setEntityLabels(String(i.id),D)})]}),e("td",{class:"mono",children:i.ipv6?"IPv6":"IPv4"}),e("td",{class:"mono",children:i.table_id}),e("td",{class:i.interface_count?"mono":"mono muted",children:i.interface_count||0}),e("td",{class:i.route_count?"mono":"mono muted",children:i.route_count||0}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:"row-action",type:"button",onClick:()=>te(`/route-tables/${i.id}?ipv6=${i.ipv6}`),children:"Routes"}),i.is_default?e("span",{class:"muted",children:"built-in"}):i.managed?e("span",{class:"muted",children:"network-managed"}):e("button",{class:"row-action danger",type:"button",disabled:!y,onClick:()=>{Io(i)},children:"Delete"})]})})]},`${i.ipv6?"v6":"v4"}-${i.id}`))})]})})]})}),e("section",{class:"panel panel-spaced","aria-labelledby":"troubleshoot-heading",hidden:Ie!=="troubleshoot",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"troubleshoot-heading",children:"Troubleshoot"}),e("p",{class:"panel-subtitle",children:"VPP packet processing graph"})]}),e("button",{class:"button ghost",type:"button",disabled:Kt,onClick:()=>{Xn(!0)},children:Kt?"Refreshing\u2026":"Refresh graph"})]}),e("p",{class:"field-hint",children:"Active VPP nodes since the last stats clear. Node size represents cycles per packet; color represents packets per call."}),rt?e("div",{class:"banner",role:"alert",children:rt}):null,Kt&&!xe?e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"Loading VPP graph\u2026"})]}):xe?e(Na,{graph:xe}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No graph loaded"}),e("p",{children:"Use Refresh graph to inspect the active VPP packet-processing nodes."})]}),xe?.generated_at?e("p",{class:"field-hint",children:["Sampled ",pt(xe.generated_at)]}):null]}),e("section",{class:"panel panel-spaced","aria-labelledby":"trace-heading",hidden:Ie!=="troubleshoot",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h2",{id:"trace-heading",children:"Packet trace"}),e("p",{class:"panel-subtitle",children:"Trace packets through a selected VPP graph node"})]})}),e(ya,{active:Ie==="troubleshoot"})]}),e("section",{class:"panel panel-spaced","aria-labelledby":"capture-heading",hidden:Ie!=="troubleshoot",children:[e("div",{class:"panel-head",children:e("div",{children:[e("h2",{id:"capture-heading",children:"Packet capture"}),e("p",{class:"panel-subtitle",children:"Capture VPP traffic and download pcap files"})]})}),e(ga,{})]}),re?e(ha,{networks:t,onClose:()=>x(!1),onSaved:i=>{x(!1),c(D=>[...D,i].sort((Y,de)=>Y.id-de.id)),R(""),le(`Created pipe ${i.network_a_name} \u2194 ${i.network_b_name}`)}}):null,ie?e(ma,{network:fe,onClose:()=>{pe(!1),ne(null),te("/networks",!0)},onSaved:i=>{let D=!!fe;pe(!1),ne(null),te("/networks",!0);let Y=D?t.map(de=>de.id===i.id?i:de):[...t,i];qe({networks:Y.sort((de,be)=>de.name.localeCompare(be.name,void 0,{sensitivity:"base"}))}),b(""),le(D?`Updated network ${i.name}`:`Created network ${i.name}`)}}):null,me?e(pa,{networks:t,vms:h,onClose:()=>{ue(!1),te("/networks/interfaces",!0)},onSaved:i=>{ue(!1),te("/networks/interfaces",!0),qe({interfaces:[...g,i].sort((D,Y)=>D.name.localeCompare(Y.name,void 0,{sensitivity:"base"}))}),S(""),le(`Created interface ${i.name}`),Qt()}}):null,U?e(Ca,{name:U.name,label:U.label,hostAddresses:U.hostAddresses,onClose:()=>Q(null)},`addresses-${U.name}`):null,Z?e(Sa,{name:Z.name,label:Z.label,tables:j,onClose:()=>q(null),onSaved:i=>{q(null),le(`Saved route tables for ${i.name}`)}},`route-tables-${Z.name}`):null,u?e(la,{iface:u,vms:h,onClose:()=>{I(null),te("/networks/interfaces",!0)},onSaved:i=>{I(null),te("/networks/interfaces",!0),qe({interfaces:g.map(D=>D.id===i.id?i:D)}),S(""),le(i.attached_vm_id?`Attached ${i.name} to ${i.attached_vm_name||"VM"}`:`Detached ${i.name}`),Qt()}},`attach-${u.id}`):null,_e?e(ra,{acl:_e.acl,vppReady:y,onClose:()=>{oe(null),te("/networks/acl",!0)},onSaved:i=>{oe(null),te("/networks/acl",!0),qe({acls:_e.acl?d.map(D=>D.id===i.id?i:D):[...d,i].sort((D,Y)=>D.name.localeCompare(Y.name,void 0,{sensitivity:"base"}))}),V(""),le(`${_e.acl?"Saved":"Added"} ACL ${i.name}`),jn(!1)}},_e.acl?`edit-${_e.acl.id}`:"new"):null,he?e(ua,{iface:he,acls:d,vppReady:y,onClose:()=>{ve(null),te("/networks/interfaces",!0)},onSaved:i=>{ve(null),te("/networks/interfaces",!0),qe({interfaces:g.map(D=>D.id===i.id?i:D)}),S(""),en(!0),le(`Applied ACLs to ${i.name}`)}},`acl-${he.id}`):null,Xe?e(fa,{iface:Xe,tables:j,onClose:()=>{ce(null),te("/networks/interfaces",!0)},onSaved:i=>{ce(null),te("/networks/interfaces",!0),qe({interfaces:g.map(D=>D.id===i.id?i:D)}),S(""),le(`Updated route tables for ${i.name}`),dt(!1)}},`route-tables-${Xe.id}`):null,p?e(ca,{networks:t,onClose:()=>N(!1),onSaved:i=>{N(!1),r(D=>[...D,i].sort((Y,de)=>Y.name.localeCompare(de.name,void 0,{sensitivity:"base"}))),J(""),le(`Created host tap ${i.name}`)}}):null]})}function Ma(t){t&&Oe(e(xa,{}),t)}function Ra(t){if(!t)return[];let n=new Set;for(let s of t.split(",")){let c=s.trim();if(!c)continue;let[a,o,f]=c.split("-"),r=Number(a);if(!Number.isInteger(r)||r<0)continue;let g=f?Number(o):r;if(!(!Number.isInteger(g)||gs-c)}function Ui(t){let n=[...new Set(t)].filter(c=>Number.isInteger(c)&&c>=0).sort((c,a)=>c-a),s=[];for(let c=0;c0?e("div",{class:"cpu-grid",role:"group","aria-label":"Allowed CPU cores",children:n.map(f=>e("button",{type:"button",class:o.has(f)?"cpu-chip selected":"cpu-chip","aria-pressed":o.has(f),disabled:a,onClick:()=>s(qi(t,f)),children:f},f))}):null,e("input",{type:"text",value:t,disabled:a,placeholder:"e.g. 0-3,7 (empty means all CPUs)",onInput:f=>s(f.currentTarget.value)}),c?e("span",{class:"field-hint",children:c}):null]})}var Fa=1024*1024;function Va(t){return t?String(Math.round(t/Fa*10)/10):""}function $a(t){let n=Number(t);return!Number.isFinite(n)||n<=0?0:Math.round(n*Fa)}function Sn(t){return t===void 0||t===0?"":String(t)}function Tn(t){let n=Number(t);return Number.isFinite(n)&&n>0?Math.floor(n):0}function En({limits:t,devices:n,onChange:s,disabled:c}){let a=new Map(n.map(d=>[d.path,d])),o=new Set(t.map(d=>d.device)),f=n.filter(d=>!o.has(d.path)),r=(d,k)=>{s(t.map((h,L)=>L===d?{...h,...k}:h))},g=()=>{let d=f[0];d&&s([...t,{device:d.path}])},w=d=>{s(t.filter((k,h)=>h!==d))};return e("div",{class:"cgroup-io-editor",children:[e("div",{class:"table-wrap",children:e("table",{class:"cgroup-io-table",children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Block device"}),e("th",{scope:"col",children:"Read MB/s"}),e("th",{scope:"col",children:"Write MB/s"}),e("th",{scope:"col",children:"Read IOPS"}),e("th",{scope:"col",children:"Write IOPS"}),e("th",{scope:"col",children:"Weight"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.length===0?e("tr",{children:e("td",{colspan:7,class:"muted",children:"No per-device I/O limits. Add a block device to constrain it."})}):t.map((d,k)=>e("tr",{children:[e("td",{children:e("select",{value:d.device,disabled:c,onChange:h=>r(k,{device:h.currentTarget.value}),children:[a.has(d.device)?null:e("option",{value:d.device,children:d.device}),n.map(h=>e("option",{value:h.path,disabled:o.has(h.path)&&h.path!==d.device,children:[h.path,h.model?` \xB7 ${h.model}`:""]},h.path))]})}),e("td",{children:e("input",{type:"number",min:0,step:"any",disabled:c,value:Va(d.read_bps),placeholder:"unlimited",onInput:h=>r(k,{read_bps:$a(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:"any",disabled:c,value:Va(d.write_bps),placeholder:"unlimited",onInput:h=>r(k,{write_bps:$a(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:1,disabled:c,value:Sn(d.read_iops),placeholder:"unlimited",onInput:h=>r(k,{read_iops:Tn(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,step:1,disabled:c,value:Sn(d.write_iops),placeholder:"unlimited",onInput:h=>r(k,{write_iops:Tn(h.currentTarget.value)})})}),e("td",{children:e("input",{type:"number",min:0,max:1e4,step:1,disabled:c,value:Sn(d.weight),placeholder:"inherit",onInput:h=>r(k,{weight:Tn(h.currentTarget.value)})})}),e("td",{children:e("button",{class:"row-action danger",type:"button",disabled:c,onClick:()=>w(k),children:"Remove"})})]},d.device))})]})}),e("div",{class:"actions",children:e("button",{class:"button ghost",type:"button",disabled:c||f.length===0,onClick:g,children:"Add block device"})})]})}var Ba=1024*1024;function Ln(t){return t?String(Math.round(t/Ba)):""}function Ha(t){let n=Number(t);return!Number.isFinite(n)||n<=0?0:Math.round(n*Ba)}function Oa(t){return t?String(t):""}function Ua(t){let n=Number(t);return Number.isFinite(n)&&n>0?Math.floor(n):0}function _t(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(1)} MB`:n>=1024?`${(n/1024).toFixed(1)} KB`:`${n} B`}function qt(t){return t===void 0?"\u2014":`${_t(t)}/s`}function Wi(t){let n=_t(t.memory_current_bytes);return t.memory_max_bytes>0?`${n} / ${_t(t.memory_max_bytes)}`:`${n} / unlimited`}function Gi(t,n){return n<=0?0:Math.max(0,Math.min(100,t/n*100))}function qa(){let[t,n]=l(null),[s,c]=l([]),[a,o]=l(!1),[f,r]=l(""),[g,w]=l(""),[d,k]=l(""),[h,L]=l(""),[T,m]=l([]),[y,C]=l([]),[E,P]=l(!0),[A,b]=l(!1),[_,R]=l(""),[H,G]=l(null),[re,x]=l({}),[F,B]=l(""),[z,J]=l(!1),se=ee(null),ge=je(),p=oe=>{n(oe),o(!!oe.enabled),r(oe.cpus||""),w(oe.mems||""),k(Ln(oe.memory_max_bytes)),L(Oa(oe.cpu_weight)),m(Array.isArray(oe.io_limits)?oe.io_limits:[]),C(Array.isArray(oe.classes)?oe.classes:[])},N=async()=>{P(!0);try{let[oe,he]=await Promise.all([M("/api/cgroup-settings"),M("/api/host/block-devices").catch(()=>[])]);p(oe),c(Array.isArray(he)?he:[]),R("")}catch(oe){R(oe instanceof Error?oe.message:String(oe))}finally{P(!1)}},X=async()=>{J(!0);try{let oe=await M("/api/cgroup-usage"),he=oe.sampled_at?new Date(oe.sampled_at).getTime():Date.now(),ve=Array.isArray(oe.nodes)?oe.nodes:[],j=se.current,$e={};if(j&&Number.isFinite(he)&&he>j.at){let O=(he-j.at)/1e3;for(let we of ve){let Te=we.path||"",W=j.nodes[Te];W&&($e[Te]={cpuCores:Math.max(0,(we.cpu_usage_usec-W.cpu)/1e6/O),read:Math.max(0,(we.io_read_bytes-W.read)/O),write:Math.max(0,(we.io_write_bytes-W.write)/O)})}}se.current={at:Number.isFinite(he)?he:Date.now(),nodes:Object.fromEntries(ve.map(O=>[O.path||"",{cpu:O.cpu_usage_usec,read:O.io_read_bytes,write:O.io_write_bytes}]))},x($e),G(oe),B("")}catch(oe){B(oe instanceof Error?oe.message:String(oe))}finally{J(!1)}};K(()=>{N()},[]);let ae=ge.segments[0]==="settings";K(()=>{if(!ae)return;X();let oe=window.setInterval(()=>{document.hidden||X()},2e3);return()=>window.clearInterval(oe)},[ae]);let $=async oe=>{if(oe.preventDefault(),!A){b(!0),R("");try{let he=await M("/api/cgroup-settings",{method:"PUT",body:JSON.stringify({enabled:a,cpus:f.trim(),mems:g.trim(),memory_max_bytes:Ha(d),cpu_weight:Ua(h),io_limits:T.map(ve=>({device:ve.device,read_bps:ve.read_bps||0,write_bps:ve.write_bps||0,read_iops:ve.read_iops||0,write_iops:ve.write_iops||0,weight:ve.weight||0})),classes:y.map(ve=>({name:ve.name.trim(),cpus:(ve.cpus||"").trim(),memory_max_bytes:ve.memory_max_bytes||0,cpu_weight:ve.cpu_weight||0,io_limits:(ve.io_limits||[]).map(j=>({device:j.device,read_bps:j.read_bps||0,write_bps:j.write_bps||0,read_iops:j.read_iops||0,write_iops:j.write_iops||0,weight:j.weight||0}))}))})});p(he),X(),le(a?"Saved cgroup resource settings":"Cgroup limits disabled")}catch(he){R(he instanceof Error?he.message:String(he))}finally{b(!1)}}},S=Array.isArray(t?.online_cpus)?t.online_cpus:[],v=!!t?.available,V=!!t?.enabled,ie=t?.reason||"",pe=V?t?.configured?`Applied at ${t?.root||"/sys/fs/cgroup/rookery-cgroup"}`:"Enabled but not applied yet":"Disabled",fe=Array.isArray(H?.nodes)?H.nodes:[],ne=fe.find(oe=>!oe.path)||null,me=fe.filter(oe=>{let he=oe.path||"";return he?!fe.some(ve=>{let j=ve.path||"";return j!==""&&j.startsWith(`${he}/`)}):!1}),ue=me.length>0?me:ne?[ne]:[],u=ue.reduce((oe,he)=>oe+(re[he.path||""]?.cpuCores||0),0),I=ue.reduce((oe,he)=>oe+(re[he.path||""]?.read||0),0),U=ue.reduce((oe,he)=>oe+(re[he.path||""]?.write||0),0),Q=H?.online_cpus||S.length||0,Z=Q>0?u/Q*100:0,q=ne?Gi(ne.memory_current_bytes,ne.memory_max_bytes):0,_e=H?.sampled_at?`sampled ${new Date(H.sampled_at).toLocaleTimeString()}`:"waiting for sample";return e("section",{class:"panel panel-spaced","aria-labelledby":"cgroup-settings-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"cgroup-settings-heading",children:"Cgroup v2 resources"}),e("p",{class:"panel-subtitle",children:"Host-wide ceilings inherited by every QEMU process"})]}),e("button",{class:"button ghost",type:"button",disabled:E||A,onClick:()=>{N(),X()},children:E?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:["Rookery runs QEMU below ",t?.root||"/sys/fs/cgroup/rookery-cgroup",". VMs without a cgroup class run in the shared child cgroup; each class is a cgroup node below the",e("code",{children:"rookery-cgroup"})," root, for example ",e("code",{children:"rookery-cgroup/prod/web"}),"."]}),e("div",{class:"cgroup-status",children:[e("span",{class:"status-badge","data-status":v?V?t?.configured?"running":"paused":"disabled":"error",children:v?V?t?.configured?"applied":"not applied":"disabled":"unavailable"}),e("span",{class:"field-hint",children:[pe,t?.mount?` \xB7 mount ${t.mount}`:"",S.length>0?` \xB7 ${S.length} online CPUs`:"",y.length>0?` \xB7 ${y.length} classes`:"",t?.cpu_model?` \xB7 ${t.cpu_model}`:""]})]}),ie?e("div",{class:"banner",role:"alert",children:ie}):null,e("section",{class:"cgroup-telemetry","aria-labelledby":"cgroup-usage-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h3",{id:"cgroup-usage-heading",children:"Live cgroup usage"}),e("p",{class:"panel-subtitle",children:["Memory, CPU, task, and I/O telemetry from the running hierarchy \xB7 ",_e]})]}),e("button",{class:"button ghost",type:"button",disabled:z,onClick:()=>{X()},children:z?"Sampling\u2026":"Refresh usage"})]}),F?e("div",{class:"banner",role:"alert",children:F}):H?H.available?H.configured?fe.length===0?e("p",{class:"field-hint",children:H.reason||"No cgroup nodes are available yet."}):e(ye,{children:[e("div",{class:"cgroup-metrics",children:[e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"Memory"}),e("strong",{children:ne?_t(ne.memory_current_bytes):"\u2014"}),e("span",{class:"field-hint",children:ne&&ne.memory_max_bytes>0?`of ${_t(ne.memory_max_bytes)} \xB7 ${q.toFixed(1)}%`:"unlimited"}),ne&&ne.memory_max_bytes>0?e("div",{class:"usage-bar",children:e("span",{style:{width:`${q}%`}})}):null]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"CPU"}),e("strong",{children:[u.toFixed(2)," cores"]}),e("span",{class:"field-hint",children:Q>0?`${Z.toFixed(1)}% of ${Q} online CPUs`:"online CPU count unavailable"})]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"Tasks"}),e("strong",{children:ne?.pids_current??"\u2014"}),e("span",{class:"field-hint",children:ne&&ne.pids_max>0?`of ${ne.pids_max}`:"no task limit"})]}),e("div",{class:"cgroup-metric",children:[e("span",{class:"field-label",children:"I/O"}),e("strong",{children:qt(I)}),e("span",{class:"field-hint",children:["read \xB7 ",qt(U)," write"]})]})]}),e("div",{class:"table-wrap",children:e("table",{class:"cgroup-usage-table",children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Scope"}),e("th",{scope:"col",children:"Memory"}),e("th",{scope:"col",children:"CPU"}),e("th",{scope:"col",children:"Tasks"}),e("th",{scope:"col",children:"I/O"})]})}),e("tbody",{children:fe.map(oe=>{let he=oe.path||"",ve=re[he];return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name mono",children:oe.name||he||"rookery-cgroup"}),he?e("span",{class:"vm-description mono",children:he}):null]}),e("td",{class:"mono",children:Wi(oe)}),e("td",{class:"mono",children:ve?`${ve.cpuCores.toFixed(2)} cores`:"\u2014"}),e("td",{class:"mono",children:[oe.pids_current,oe.pids_max>0?` / ${oe.pids_max}`:""]}),e("td",{class:"mono",children:ve?`\u2193 ${qt(ve.read)} \xB7 \u2191 ${qt(ve.write)}`:"\u2014"})]},he||"root")})})]})})]}):e("p",{class:"field-hint",children:H.reason||"Enable cgroup limits to start collecting resource usage."}):e("p",{class:"field-hint",children:"cgroup v2 is not available on this host."}):e("p",{class:"field-hint",children:"Waiting for the first cgroup sample\u2026"})]}),e("form",{class:"form-grid",onSubmit:oe=>{$(oe)},children:[e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:a,onChange:oe=>o(oe.currentTarget.checked)}),e("span",{children:"Enable cgroup v2 limits for QEMU"})]}),e("span",{class:"field-hint",children:"Saving applies the new host defaults to every running VM immediately: Rookery re-places each live QEMU process in its shared or class cgroup. New and restarted VMs are always placed under this hierarchy. When disabled, running QEMU processes are moved out immediately and the Rookery cgroups are removed; all VMs then run unconstrained, and class limits take effect again when host limits are enabled. Shrinking the host CPU set clamps running VMs to the remaining CPUs."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CPU cores"}),e(Pn,{value:f,options:S,disabled:!a,onChange:r,hint:"Click cores to pin QEMU to them. The text field accepts ranges. Empty means all online CPUs."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Total memory limit (MB)"}),e("input",{type:"number",min:0,step:128,disabled:!a,value:d,placeholder:"unlimited",onInput:oe=>k(oe.currentTarget.value)}),e("span",{class:"field-hint",children:"memory.max for the whole Rookery cgroup subtree. Empty means unlimited."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"CPU weight"}),e("input",{type:"number",min:0,max:1e4,step:1,disabled:!a,value:h,placeholder:"default (100)",onInput:oe=>L(oe.currentTarget.value)}),e("span",{class:"field-hint",children:"cpu.weight (1\u201310000). Empty uses the kernel default of 100."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Memory nodes ",e("em",{children:"optional"})]}),e("input",{type:"text",disabled:!a,value:g,placeholder:"e.g. 0-1 (empty means all)",onInput:oe=>w(oe.currentTarget.value)}),e("span",{class:"field-hint",children:"cpuset.mems NUMA node list."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Per-device I/O limits"}),e(En,{limits:T,devices:s,disabled:!a,onChange:m}),s.length===0?e("span",{class:"field-hint",children:["No whole block devices were reported by the host. Check access to ",e("code",{children:"/sys/class/block"}),"."]}):null,e("span",{class:"field-hint",children:"io.max limits read/write bandwidth (MiB/s) and IOPS. io.weight sets the proportional weight for that device (1\u201310000; requires the BFQ scheduler)."})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Classes"}),e("span",{class:"field-hint",children:["Define a tree of reusable profiles below ",e("code",{children:"rookery-cgroup"}),". Class paths are slash-separated and may be up to 3 levels deep, for example ",e("code",{children:"prod"}),","," ",e("code",{children:"prod/web"}),", or ",e("code",{children:"prod/web/frontend"}),". Define the parent class first. VMs can only be assigned to leaf classes; parent classes group their children and inherit limits. Assign a class to a VM instead of entering limits per VM. Class limits are shared by every VM assigned to that class; allow enough memory for all of them."]}),e(Xi,{classes:y,devices:s,onlineCPUs:S,disabled:E||A,onChange:C})]}),_?e("p",{class:"form-error field-wide",role:"alert",children:_}):null,e("div",{class:"field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:A||E,children:A?"Saving\u2026":"Save cgroup settings"})})]})]})}function zi(t,n){let s=1;for(;;){let c=n?`${n}/class-${s}`:`class-${s}`;if(!t.some(a=>a.name===c))return c;s++}}function Wa(t){let n=t.lastIndexOf("/");return n>0?t.slice(0,n):""}function Ki(t){let n=t.lastIndexOf("/");return n>=0?t.slice(n+1):t}function ji(t,n,s){let c=s.trim();if(!c||c.includes("/"))return null;let a=Wa(n),o=a?`${a}/${c}`:c;return o===n?t:t.some(f=>f.name===o)?null:t.map(f=>f.name===n?{...f,name:o}:f.name.startsWith(n+"/")?{...f,name:o+f.name.slice(n.length)}:f)}function Ji(t){let n=[];return t.cpus&&n.push(`CPU ${t.cpus}`),t.memory_max_bytes&&n.push(`${Ln(t.memory_max_bytes)} MB`),t.cpu_weight&&n.push(`weight ${t.cpu_weight}`),t.io_limits?.length&&n.push(`${t.io_limits.length} I/O`),n.length?n.join(" \xB7 "):"inherits host limits"}function Xi({classes:t,devices:n,onlineCPUs:s,disabled:c,onChange:a}){let o=(h,L)=>{a(t.map(T=>T.name===h?{...T,...L}:T))},f=h=>{a(t.filter(L=>L.name!==h&&!L.name.startsWith(h+"/")))},r=h=>{a([...t,{name:zi(t,h)}])},g=new Map(t.map(h=>[h.name,h])),w=new Map,d=[];for(let h of t){let L=Wa(h.name);if(L&&g.has(L)){let T=w.get(L)||[];T.push(h),w.set(L,T)}else d.push(h)}for(let h of w.values())h.sort((L,T)=>L.name.localeCompare(T.name));d.sort((h,L)=>h.name.localeCompare(L.name));let k=h=>{let L=h.name.split("/").length,T=w.get(h.name)||[],m=Ki(h.name);return e("div",{class:"cgroup-tree-node",children:[e("div",{class:"cgroup-tree-node-head",children:[e("div",{class:"cgroup-tree-title",children:[e("input",{class:"cgroup-tree-name",type:"text",defaultValue:m,disabled:c,"aria-label":`Class name for ${h.name}`,onBlur:y=>{let C=ji(t,h.name,y.currentTarget.value);if(!C){y.currentTarget.value=m;return}a(C)}}),e("span",{class:"cgroup-tree-path",children:h.name}),e("span",{class:"field-hint",children:Ji(h)})]}),e("div",{class:"actions",children:[L<3?e("button",{class:"row-action",type:"button",disabled:c||t.length>=64,onClick:()=>r(h.name),children:"Add child"}):null,e("button",{class:"row-action danger",type:"button",disabled:c||T.length>0,title:T.length>0?"Remove child classes first.":"",onClick:()=>f(h.name),children:"Remove"})]})]}),e("details",{class:"cgroup-tree-limits",children:[e("summary",{children:"Limits"}),e("div",{class:"form-grid cgroup-class-grid",children:[e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"CPU cores"}),e(Pn,{value:h.cpus||"",options:s,disabled:c,onChange:y=>o(h.name,{cpus:y}),hint:"Empty inherits the host/parent class CPU set. A non-empty set must be a subset of it."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory limit (MB)"}),e("input",{type:"number",min:0,step:128,disabled:c,value:Ln(h.memory_max_bytes),placeholder:"inherit",onInput:y=>o(h.name,{memory_max_bytes:Ha(y.currentTarget.value)})})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"CPU weight"}),e("input",{type:"number",min:0,max:1e4,step:1,disabled:c,value:Oa(h.cpu_weight),placeholder:"inherit",onInput:y=>o(h.name,{cpu_weight:Ua(y.currentTarget.value)})})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Per-device I/O limits"}),e(En,{limits:h.io_limits||[],devices:n,disabled:c,onChange:y=>o(h.name,{io_limits:y})})]})]})]}),T.length>0?e("div",{class:"cgroup-tree-children",children:T.map(k)}):null]},h.name)};return e("div",{class:"cgroup-tree",children:[e("div",{class:"cgroup-tree-root",children:[e("div",{children:[e("span",{class:"cgroup-tree-root-name",children:"rookery-cgroup"}),e("span",{class:"field-hint",children:"Root limits are the host settings above; classes nest up to 3 levels below."})]}),e("button",{class:"button ghost",type:"button",disabled:c||t.length>=64,onClick:()=>r(),children:"Add top-level class"})]}),d.length>0?d.map(k):e("p",{class:"field-hint",children:"No classes defined. VMs without a class run in the shared cgroup."})]})}function Zi(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function Yi(t){switch(t){case"success":return"running";case"failed":return"error";case"running":return"starting";case"pending":return"paused";default:return"disabled"}}function Ga({kind:t,title:n,endpoint:s,description:c,placeholder:a,spaced:o}){let[f,r]=l(null),[g,w]=l(!1),[d,k]=l(""),[h,L]=l(!0),[T,m]=l(!1),[y,C]=l(""),E=t==="rookery"?"Rookery":"VPP",P=`${t}-startup-script-heading`,A=async()=>{L(!0);try{let G=await M(s);r(G),w(!!G.enabled),k(G.script||""),C("")}catch(G){C(G instanceof Error?G.message:String(G))}finally{L(!1)}};K(()=>{A()},[]);let b=async G=>{if(G.preventDefault(),!T){if(g&&!d.trim()){C(`Enter a ${E} script before enabling it.`);return}m(!0),C("");try{let re=await M(s,{method:"PUT",body:JSON.stringify({enabled:g,script:d})});r(re),w(!!re.enabled),k(re.script||""),le(re.enabled?`Saved ${E} startup script${t==="rookery"?"; it will run on the next API start":"; it will run on the next VPP connection"}`:`Saved ${E} startup script settings`)}catch(re){C(re instanceof Error?re.message:String(re))}finally{m(!1)}}},_=f?.status||"disabled",R=f?.output||"",H=_==="success"?0:f?.exit_code??"\u2014";return e("section",{class:o?"panel panel-spaced":"panel","aria-labelledby":P,children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:P,children:n}),e("p",{class:"panel-subtitle",children:t==="rookery"?"Rookery server lifecycle hook":"Applied whenever Rookery connects to FD.io VPP"})]}),e("button",{class:"button ghost",type:"button",disabled:h||T,onClick:()=>{A()},children:h?"Refreshing\u2026":"Refresh"})]}),e("p",{class:"field-hint",children:c}),e("div",{class:"startup-script-status",children:[e("span",{class:"status-badge","data-status":Yi(_),children:_}),_==="success"||_==="failed"?e("span",{class:"field-hint",children:["exit code ",H,f?.ran_at?` \xB7 ${Zi(f.ran_at)}`:""]}):_==="running"?e("span",{class:"field-hint",children:"Running\u2026"}):g?e("span",{class:"field-hint",children:t==="rookery"?"Runs on the next API start.":"Runs on the next VPP connection."}):e("span",{class:"field-hint",children:"Disabled."})]}),e("form",{class:"form-grid",onSubmit:G=>{b(G)},children:[e("label",{class:"field field-wide checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:g,onChange:G=>w(G.currentTarget.checked)}),e("span",{children:["Enable ",E," startup script"]})]})}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Script"}),e("textarea",{class:"startup-script-editor",rows:t==="rookery"?7:9,spellcheck:!1,placeholder:a,value:d,onInput:G=>k(G.currentTarget.value)}),e("span",{class:"field-hint",children:"The script runs with `/bin/sh` and the API process privileges. Only configure trusted commands."})]}),y?e("p",{class:"form-error field-wide",role:"alert",children:y}):null,e("div",{class:"field-wide actions",children:e("button",{class:"button primary",type:"submit",disabled:T||h,children:T?"Saving\u2026":`Save ${E} startup script`})})]}),R?e("details",{class:"startup-script-output",open:_==="failed",children:[e("summary",{children:"Last output"}),e("pre",{children:R})]}):null]})}function za(){return e(ye,{children:[e(qa,{}),e(Ga,{kind:"rookery",title:"Rookery startup script",endpoint:"/api/startup-script",description:"Runs once per API process after Rookery starts. It is empty by default. Use it for host-level setup that should happen once when the service starts.",placeholder:`# Runs once when the Rookery API starts +# Add host or setup commands here`,spaced:!0}),e(Ga,{kind:"vpp",title:"VPP startup script",endpoint:"/api/vpp-startup-script",description:"Runs every time Rookery connects to FD.io VPP, including after the connection drops or VPP restarts. It is enabled by default to assign NAT44 workers.",placeholder:`# Assign NAT44 workers +vppctl set nat workers 0`,spaced:!0})]})}function Ka(t){t&&Oe(e(za,{}),t)}function Wt(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}var Qi="[A-Za-z0-9][A-Za-z0-9._\\-]*";function Nn(t,n){if(!t)return"";let[s,c]=t.trim().split("/"),a=s.split(".").map(Number);if(a.length!==4||a.some(f=>!Number.isInteger(f)||f<0||f>255)||!c)return"";let o=(a[0]<<24>>>0)+(a[1]<<16)+(a[2]<<8)+a[3]+n>>>0;return`${o>>>24&255}.${o>>>16&255}.${o>>>8&255}.${o&255}`}function er(t,n){if(!t)return"";let[s,c]=t.trim().split("/");if(!s||!c)return"";let a=s.split("::");if(a.length>2)return"";let o=h=>h?h.split(":"):[],f=o(a[0]),r=o(a[1]??""),g=8-f.length-r.length;if(g<0)return"";let w=[...f,...Array.from({length:g},()=>"0"),...r];if(w.some(h=>!/^[0-9a-fA-F]{1,4}$/.test(h)))return"";let d=0n;for(let h of w)d=(d<<16n)+BigInt(parseInt(h,16));d+=BigInt(n);let k=[];for(let h=0;h<8;h+=1)k.unshift((d&0xffffn).toString(16)),d>>=16n;return k.join(":")}function Dn(t){let n=Nn(t,1);return!n||!t?"":`${n}/${t.trim().split("/")[1]}`}function tr(t,n,s,c){let a=t?.type??"dhcpv4",o=t?.config??{},f=n.find(g=>g.id===(t?.network_id??c??n[0]?.id)),r=Dn(f?.ipv4_cidr)||"10.0.0.1/16";return{name:t?.name??"",networkID:t?.network_id??c??n[0]?.id??0,type:a,enabled:t?t.enabled:s,gateway:o.gateway??(a==="dhcpv6"?"fd00:20::1/64":r),dns:o.dns??(a==="dhcpv6"?"2001:4860:4860::8888":"1.1.1.1"),rangeStart:o.range_start??(a==="dhcpv6"?"fd00:20::100":"10.20.0.100"),rangeEnd:o.range_end??(a==="dhcpv6"?"fd00:20::200":"10.20.0.200"),leaseTime:o.lease_time??"12h",outsideInterface:o.outside_interface??"",outsideAddress:o.outside_address??"",outsideGateway:o.outside_gateway??"",nat44Pool:o.nat44_pool??"",dnatRules:Array.isArray(o.dnat_rules)?o.dnat_rules.map(g=>({...g})):[],staticAssignments:Array.isArray(o.static_assignments)?o.static_assignments.map(g=>({...g})):[],dhcpOptions:Array.isArray(o.dhcp_options)?o.dhcp_options.map(g=>({...g})):[],dnsRecords:Array.isArray(o.dns_records)?o.dns_records.map(g=>({...g})):[],dnsForward:o.dns_forward??!1,dnsForwarders:Array.isArray(o.dns_forwarders)?o.dns_forwarders.join(", "):t?.type==="dns"?"":"1.1.1.1, 8.8.8.8",dnsVNFID:o.dns_vnf_id??0,dnsDomain:o.dns_domain??"",dnsTTL:String(o.dns_ttl??300),transport:a==="bgp"?o.transport==="tap"?"tap":"memif":o.transport==="shared"||o.transport==="dedicated"||o.transport==="tap"?o.transport:"inherit",localAS:String(o.local_as??64512),routerID:o.router_id??"10.20.0.1",peerAS:String(o.peer_as??64513),peerIP:o.peer_ip??"169.254.0.2",localIP:o.local_ip??"169.254.0.1/30",peerGateway:o.peer_gateway??"",nextHop:o.next_hop??"",multihopTTL:String(o.multihop_ttl??1),password:o.password??"",passive:o.passive??!1,targetTableID:String(o.target_table_id??0),advertisePrefixes:Array.isArray(o.advertise_prefixes)?o.advertise_prefixes.join(` +`):""}}function nr(t,n){return n==="dns"?{...t,type:n,dnsRecords:[],dnsForward:!0,dnsForwarders:"1.1.1.1, 8.8.8.8"}:n==="bgp"?{...t,type:n,transport:"memif",localAS:"64512",routerID:"10.20.0.1",peerAS:"64513",peerIP:"169.254.0.2",localIP:"169.254.0.1/30",peerGateway:"",nextHop:"",multihopTTL:"1",password:"",passive:!1,targetTableID:"0",advertisePrefixes:""}:n==="dhcpv6"?{...t,type:n,transport:"tap",gateway:"fd00:20::1/64",dns:"2001:4860:4860::8888",rangeStart:"fd00:20::100",rangeEnd:"fd00:20::200",leaseTime:"12h",dnsVNFID:0,dnsDomain:"",dnsTTL:"300"}:n==="nat44"?{...t,type:n,gateway:"10.20.0.1/24",outsideAddress:"",outsideGateway:"",nat44Pool:""}:{...t,type:n,transport:"inherit",gateway:"10.20.0.1/24",dns:"1.1.1.1",rangeStart:"10.20.0.100",rangeEnd:"10.20.0.200",leaseTime:"12h",dnsVNFID:0,dnsDomain:"",dnsTTL:"300"}}function sr(t,n){let s=t.find(c=>c.name===n);return s?.default_route?"This NIC carries the host default route and cannot be used for NAT.":s?.physical===!1?"Select a wired physical NIC; virtual and VPP-managed interfaces are not valid outside uplinks.":s?.wireless?"Wireless NICs are not supported as VPP NAT44 outside uplinks; use a wired NIC.":s?`VPP will take over ${s.name} and flush its host addresses.`:"The selected NIC will be taken over by VPP; its host addresses are flushed."}function ar(t){return t==="dns"?"Local DNS server on the network service address (.2) over IPv4 and IPv6. Serve A/AAAA/CNAME/TXT/PTR records and optionally forward other names upstream.":t==="bgp"?"Embedded IPv4/IPv6 BGP speaker. Select memif for userspace/gVisor or tap for kernel TCP with TCP MD5 support.":t==="nat44"?"IPv4 NAT44 gateway. A dedicated network BVI owns the gateway; the outside NIC is taken over by VPP.":t==="dhcpv6"?"IPv6 address pool with stateful IA_NA leases on a VPP tap (shared memif is DHCPv4-only for now).":"IPv4 address pool served on the network-shared memif by default (VNF address .2)."}var or=[{code:3,label:"Router",format:"ipv4",placeholder:"10.20.0.1",hint:"Overrides the gateway router option."},{code:6,label:"DNS servers",format:"ipv4",placeholder:"10.20.0.2, 1.1.1.1",hint:"Overrides the DNS field."},{code:15,label:"Domain name",format:"string",placeholder:"lab.test",hint:"This is the domain name, not DNS server addresses. Use code 6 for DNS servers."},{code:42,label:"NTP servers",format:"ipv4",placeholder:"10.20.0.1, 10.20.0.2"},{code:44,label:"WINS servers",format:"ipv4",placeholder:"10.20.0.10"},{code:66,label:"TFTP server name",format:"string",placeholder:"tftp.lab.test"},{code:67,label:"Bootfile name",format:"string",placeholder:"pxelinux.0"},{code:119,label:"Domain search list",format:"domain-list",placeholder:"lab.test, apps.lab.test"},{code:252,label:"WPAD URL",format:"string",placeholder:"http://wpad.lab.test/wpad.dat"}],ir=[{code:21,label:"SIP domain names",format:"domain-list",placeholder:"sip.lab.test"},{code:22,label:"SIP servers",format:"ipv6",placeholder:"fd00:20::10"},{code:23,label:"DNS recursive name servers",format:"ipv6",placeholder:"fd00:20::2",hint:"Overrides the DNS field."},{code:24,label:"Domain search list",format:"domain-list",placeholder:"lab.test, apps.lab.test"},{code:31,label:"SNTP servers",format:"ipv6",placeholder:"fd00:20::10"},{code:59,label:"Bootfile URL",format:"string",placeholder:"http://[fd00:20::1]/boot"},{code:60,label:"Bootfile parameters",format:"string",placeholder:"console=ttyS0"}];function ja(t){return t==="dhcpv6"?ir:or}function In(t,n){return ja(t).find(s=>s.code===n)}function Ja({vnf:t,networks:n,vnfs:s,vppReady:c,initialNetworkID:a,onClose:o,onSaved:f}){let[r,g]=l(()=>tr(t,n,c,a)),[w,d]=l([]),[k,h]=l(!1),[L,T]=l(!1),[m,y]=l(""),[C,E]=Pe("vnf",t?.id),P=ee(null),A=ee(null),b=t!==null,_=r.type==="nat44",R=r.type==="bgp",H=r.type==="dns",G=n.find(u=>u.id===r.networkID),re=Nn(G?.ipv4_cidr,2),x=er(G?.ipv6_cidr,2),F=s.filter(u=>u.type==="dns"&&u.network_id===r.networkID),B=(()=>{let u=G?.ipv4_cidr?.trim(),I=Nn(u,2),U=u?.split("/")[1];return I&&U?`${I}/${U}`:""})(),z=r.gateway||"the gateway address";K(()=>{P.current?.focus(),document.title=`${t?"Edit":"Add"} VNF \xB7 rookery`},[t]),K(()=>{if(!_)return;let u=!1;return h(!0),M("/api/host/interfaces").then(I=>{u||d(Array.isArray(I)?I:[])}).catch(()=>{u||d([])}).finally(()=>{u||h(!1)}),()=>{u=!0}},[_]);let J=(u,I)=>{g(U=>({...U,[u]:I}))},se=()=>{L||o()},ge=async u=>{if(u.preventDefault(),L||!A.current?.reportValidity())return;if(y(""),!R&&!H&&!r.gateway.trim()){y("Gateway CIDR is required.");return}if(!R&&!_&&!H&&(!r.rangeStart.trim()||!r.rangeEnd.trim())){y("Range start and end are required.");return}if(_&&!r.outsideInterface){y("Select an outside network interface.");return}if(_&&r.enabled&&!window.confirm(`Take over ${r.outsideInterface} for NAT44? -VPP will own this NIC and its host IPv4/IPv6 addresses will be flushed. The host default-route interface is rejected by the server.`))return;let _=r.advertisePrefixes.split(/[\n,]+/).map(Z=>Z.trim()).filter(Boolean),B=H?{transport:r.transport==="tap"?"tap":"memif",local_as:Number(r.localAS),router_id:r.routerID.trim(),peer_as:Number(r.peerAS),peer_ip:r.peerIP.trim(),local_ip:r.localIP.trim(),peer_gateway:r.peerGateway.trim(),next_hop:r.nextHop.trim(),multihop_ttl:Number(r.multihopTTL),password:r.transport==="tap"?r.password:"",passive:r.passive,target_table_id:Number(r.targetTableID),advertise_prefixes:_}:G?{dns_records:r.dnsRecords.map(Z=>({name:Z.name.trim(),type:Z.type,value:Z.value.trim(),ttl:Number(Z.ttl)||0})),dns_forward:r.dnsForward,dns_forwarders:r.dnsForwarders.split(/[\n,]+/).map(Z=>Z.trim()).filter(Boolean)}:T?{gateway:r.gateway.trim(),outside_interface:r.outsideInterface,outside_address:r.outsideAddress.trim(),outside_gateway:r.outsideGateway.trim(),nat44_pool:r.nat44Pool.trim(),dnat_rules:r.dnatRules.map(Z=>({protocol:Z.protocol,external_port:Number(Z.external_port),local_ip:Z.local_ip.trim(),local_port:Number(Z.local_port)}))}:{gateway:r.gateway.trim(),range_start:r.rangeStart.trim(),range_end:r.rangeEnd.trim(),dns:r.dns.trim(),lease_time:r.leaseTime.trim()||"12h",dhcp_options:r.dhcpOptions.map(Z=>({code:Number(Z.code),format:Z.format,value:Z.value.trim()})),...r.transport==="shared"||r.transport==="dedicated"||r.transport==="tap"?{transport:r.transport}:{},...r.type==="dhcpv4"?{static_assignments:r.staticAssignments.map(Z=>({mac:Z.mac.trim(),ip:Z.ip.trim()}))}:{},...r.dnsVNFID>0?{dns_vnf_id:r.dnsVNFID,dns_domain:r.dnsDomain.trim(),dns_ttl:Number(r.dnsTTL)||300}:{}},X={name:r.name.trim(),enabled:r.enabled,config:B};p||(X.network_id=r.networkID,X.type=r.type),I(!0);try{let Z=await V(p?`/api/vnfs/${t?.id}`:"/api/vnfs",{method:p?"PUT":"POST",body:JSON.stringify(X)});await Ce("vnf",Z.id,Z.name,P),f(Z)}catch(Z){g(Z instanceof Error?Z.message:String(Z))}finally{I(!1)}},E=()=>{b(c=>({...c,dnatRules:[...c.dnatRules,{protocol:"tcp",external_port:8080,local_ip:"",local_port:80}]}))},M=(c,_,B)=>{b(X=>({...X,dnatRules:X.dnatRules.map((Z,fe)=>fe===c?{...Z,[_]:B}:Z)}))},se=c=>{b(_=>({..._,dnatRules:_.dnatRules.filter((B,X)=>X!==c)}))},ce=()=>{b(c=>({...c,staticAssignments:[...c.staticAssignments,{mac:"",ip:""}]}))},v=(c,_,B)=>{b(X=>({...X,staticAssignments:X.staticAssignments.map((Z,fe)=>fe===c?{...Z,[_]:B}:Z)}))},$=c=>{b(_=>({..._,staticAssignments:_.staticAssignments.filter((B,X)=>X!==c)}))},C=()=>{b(c=>({...c,dhcpOptions:[...c.dhcpOptions,{code:0,format:"string",value:""}]}))},U=(c,_,B)=>{b(X=>({...X,dhcpOptions:X.dhcpOptions.map((Z,fe)=>fe===c?{...Z,[_]:B}:Z)}))},de=(c,_)=>{let B=Ln(r.type,_);b(X=>({...X,dhcpOptions:X.dhcpOptions.map((Z,fe)=>fe===c?{...Z,code:_,...B?{format:B.format}:{}}:Z)}))},ie=c=>{b(_=>({..._,dhcpOptions:_.dhcpOptions.filter((B,X)=>X!==c)}))},pe=()=>{b(c=>({...c,dnsRecords:[...c.dnsRecords,{name:"",type:"A",value:"",ttl:300}]}))},ee=(c,_,B)=>{b(X=>({...X,dnsRecords:X.dnsRecords.map((Z,fe)=>fe===c?{...Z,[_]:B}:Z)}))},ue=c=>{b(_=>({..._,dnsRecords:_.dnsRecords.filter((B,X)=>X!==c)}))},me=H?l?r.transport==="memif"?"Creates a memif and starts the embedded BGP speaker on gVisor. TCP MD5 is unavailable on this transport.":"Creates a VPP tap and starts the embedded BGP speaker on kernel TCP; TCP MD5 is supported.":"VPP is not ready. The BGP VNF can be created disabled and started later.":G?l?"Starts a DNS server on the network service address (.2, UDP/TCP 53) using the shared VNF memif.":"VPP is not ready. The DNS VNF can be created disabled and started later.":T?l?r.enabled?"Saving takes over the selected outside NIC and flushes its host addresses.":"The configuration is saved without taking over the outside NIC.":"VPP is not ready. Save disabled and start the NAT44 VNF later.":l?"Creates a VPP tap and starts the function immediately.":"VPP is not ready. The VNF can be created disabled and started later.";return e("div",{class:"vnf-page",children:e("form",{ref:N,class:"vnf-page-form",novalidate:!0,onSubmit:c=>{ae(c)},children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",disabled:A,onClick:y,children:"\u2190 VNFs"}),e("p",{class:"eyebrow",children:"Network function"}),e("h2",{id:"vnf-dialog-title",children:p?`Edit ${t?.name??"VNF"}`:"Add VNF"})]})}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:S,type:"text",required:!0,maxlength:64,pattern:Zi,autocomplete:"off",spellcheck:!1,placeholder:"lab-dhcpv4",value:r.name,onInput:c=>Y("name",c.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network"}),e("select",{required:!0,disabled:p,value:String(r.networkID),onChange:c=>{let _=Number(c.currentTarget.value),B=n.find(X=>X.id===_);b(X=>({...X,networkID:_,dnsVNFID:s.some(Z=>Z.id===X.dnsVNFID&&Z.type==="dns"&&Z.network_id===_)?X.dnsVNFID:0,gateway:X.type==="dhcpv6"?X.gateway:An(B?.ipv4_cidr)||X.gateway}))},children:n.map(c=>e("option",{value:String(c.id),children:[c.name," \xB7 bd ",c.bridge_domain_id]},c.id))})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Type"}),e("select",{required:!0,disabled:p,value:r.type,onChange:c=>{if(!p){let _=c.currentTarget.value;b(B=>{let X=Qi(B,_),Z=n.find(fe=>fe.id===X.networkID);return(_==="dhcpv4"||_==="nat44")&&Z?.ipv4_cidr&&(X.gateway=An(Z.ipv4_cidr)||X.gateway),X})}},children:[e("option",{value:"dhcpv4",children:"DHCPv4"}),e("option",{value:"dhcpv6",children:"DHCPv6"}),e("option",{value:"nat44",children:"NAT44"}),e("option",{value:"bgp",children:"BGP"}),e("option",{value:"dns",children:"DNS"})]}),e("span",{class:"field-hint",children:tr(r.type)})]}),!H&&!G?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Gateway CIDR"}),e("input",{type:"text",required:!0,placeholder:T?"10.20.0.1/24 (network BVI gateway)":r.type==="dhcpv6"?"fd00:20::1/64":"10.20.0.1/24",autocomplete:"off",spellcheck:!1,value:r.gateway,onInput:c=>Y("gateway",c.currentTarget.value)}),e("span",{class:"field-hint",children:["Network range: ",n.find(c=>c.id===r.networkID)?.ipv4_cidr||"not set"]})]}):null]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:e(Se,{labels:P,onChange:L,disabled:A})})}),!T&&!H&&!G?e(ge,{children:[e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:["DNS servers ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:r.type==="dhcpv6"?"2001:4860:4860::8888":"1.1.1.1,8.8.8.8",autocomplete:"off",spellcheck:!1,value:r.dns,onInput:c=>Y("dns",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Range start"}),e("input",{type:"text",required:!0,placeholder:r.type==="dhcpv6"?"fd00:20::100":"10.20.0.100",autocomplete:"off",spellcheck:!1,value:r.rangeStart,onInput:c=>Y("rangeStart",c.currentTarget.value)})]}),r.type==="dhcpv4"?e("label",{class:"field",children:[e("span",{class:"field-label",children:"VNF networking"}),e("select",{value:r.transport,onChange:c=>Y("transport",c.currentTarget.value),children:[e("option",{value:"inherit",children:"Inherit network default (shared)"}),e("option",{value:"shared",children:["Shared network memif",F?` \xB7 ${F}`:""]}),e("option",{value:"dedicated",children:["Dedicated memif",W?` \xB7 ${W}`:""]}),e("option",{value:"tap",children:["Tap",W?` \xB7 ${W}`:""]})]}),e("span",{class:"field-hint",children:r.transport==="shared"?`The VNF uses the shared ${F||".2 service"} address; the BVI owns the gateway (.1).`:r.transport==="dedicated"?`The VNF uses ${W}; on a NAT network the server may move it to the .2 service address.`:r.transport==="tap"?`The tap is assigned ${W}; on a NAT network the server may move it to the .2 service address.`:`Inherits the network default (${J?.vnf_transport||"shared"}). Shared uses ${F||".2"}.`})]}):null,e("label",{class:"field",children:[e("span",{class:"field-label",children:"Range end"}),e("input",{type:"text",required:!0,placeholder:r.type==="dhcpv6"?"fd00:20::200":"10.20.0.200",autocomplete:"off",spellcheck:!1,value:r.rangeEnd,onInput:c=>Y("rangeEnd",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Lease time"}),e("input",{type:"text",placeholder:"12h",autocomplete:"off",spellcheck:!1,value:r.leaseTime,onInput:c=>Y("leaseTime",c.currentTarget.value)})]})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["DHCP DNS integration ",e("em",{children:"optional"})]}),e("select",{value:String(r.dnsVNFID),onChange:c=>{let _=Number(c.currentTarget.value),B=r.type==="dhcpv6"?x:ne;b(X=>({...X,dnsVNFID:_,..._>0&&B?{dns:B}:{}}))},children:[e("option",{value:"0",children:"No DNS integration"}),R.map(c=>e("option",{value:String(c.id),children:c.name},c.id))]}),e("span",{class:"field-hint",children:R.length===0?"Create a DNS VNF on this network first to register DHCP leases.":"Active leases get A/AAAA and PTR records in the selected DNS VNF. The DNS server field is set to its service address (.2)."})]}),r.dnsVNFID>0?e(ge,{children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"DNS domain"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"lab1.test",value:r.dnsDomain,onInput:c=>Y("dnsDomain",c.currentTarget.value)}),e("span",{class:"field-hint",children:"Used when the client does not send a FQDN."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"DNS TTL (seconds)"}),e("input",{type:"number",min:1,max:86400,value:r.dnsTTL,onInput:c=>Y("dnsTTL",c.currentTarget.value)})]})]}):null]}),r.type==="dhcpv4"?e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["Static assignments ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:"Reserve an IPv4 address for a MAC address. Reserved addresses are excluded from the dynamic pool and can be inside or outside the DHCP range."})]}),e("button",{class:"button ghost",type:"button",onClick:ce,children:"Add assignment"})]}),e("div",{class:"acl-rule-rows",children:r.staticAssignments.map((c,_)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Assignment ",_+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>$(_),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dhcp-static-field",children:[e("span",{class:"field-label",children:"MAC address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"02:00:00:00:00:01",value:c.mac,onInput:B=>v(_,"mac",B.currentTarget.value)})]}),e("label",{class:"field dhcp-static-field",children:[e("span",{class:"field-label",children:"IP address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,inputMode:"decimal",placeholder:"10.20.0.50",value:c.ip,onInput:B=>v(_,"ip",B.currentTarget.value)})]})]})]},_))})]})}):null,e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["Extra DHCP options ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:["Advertise additional options such as option 15 (domain name), 42 (NTP), 119 (domain search), or DHCPv6 option 24 (domain search). Use",e("code",{children:"string"}),", ",e("code",{children:"ipv4"}),", ",e("code",{children:"ipv6"}),", ",e("code",{children:"hex"}),",",e("code",{children:"uint8"}),", ",e("code",{children:"uint16"}),", ",e("code",{children:"uint32"}),", or",e("code",{children:"domain-list"}),". DNS server options are set through the DNS field."]})]}),e("button",{class:"button ghost",type:"button",onClick:C,children:"Add option"})]}),e("div",{class:"acl-rule-rows",children:[r.dhcpOptions.map((c,_)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Option ",_+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>ie(_),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dhcp-option-code",children:[e("span",{class:"field-label",children:"Code"}),e("input",{type:"number",required:!0,min:1,max:r.type==="dhcpv6"?65535:254,list:`dhcp-option-codes-${r.type}`,value:c.code||"",onInput:B=>de(_,Number(B.currentTarget.value))}),(()=>{let B=Ln(r.type,c.code);return e("span",{class:"field-hint",children:B?`${B.label}${B.hint?` \u2014 ${B.hint}`:""}`:"Custom option code"})})()]}),e("label",{class:"field dhcp-option-format",children:[e("span",{class:"field-label",children:"Format"}),e("select",{value:c.format,onChange:B=>U(_,"format",B.currentTarget.value),children:[e("option",{value:"string",children:"string"}),e("option",{value:"ipv4",children:"ipv4"}),e("option",{value:"ipv6",children:"ipv6"}),e("option",{value:"hex",children:"hex"}),e("option",{value:"uint8",children:"uint8"}),e("option",{value:"uint16",children:"uint16"}),e("option",{value:"uint32",children:"uint32"}),e("option",{value:"domain-list",children:"domain-list"})]})]}),e("label",{class:"field dhcp-option-value",children:[e("span",{class:"field-label",children:"Value"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:Ln(r.type,c.code)?.placeholder??(c.format==="ipv4"?"10.20.0.1, 10.20.0.2":c.format==="ipv6"?"fd00:20::1":c.format==="domain-list"?"lab.test, apps.lab.test":c.format==="hex"?"01:02:ff":"lab.test"),value:c.value,onInput:B=>U(_,"value",B.currentTarget.value)})]})]})]},_)),r.dhcpOptions.length===0?e("p",{class:"field-hint",children:"No extra options. Example: code 15, format string, value lab.test advertises the domain name. DNS and router options override the DNS field and gateway."}):null]}),e("datalist",{id:`dhcp-option-codes-${r.type}`,children:za(r.type).map(c=>e("option",{value:c.code,label:`${c.label} (${c.format})`}))})]})})]}):G?e(ge,{children:[e("p",{class:"field-hint",children:["DHCPv4 clients can use ",e("code",{children:ne||"the IPv4 .2 service address"}),"; DHCPv6 clients can use ",e("code",{children:x||"the IPv6 .2 service address"}),". Set that address in the DHCP VNF's DNS field (or configure it directly on clients)."]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:"DNS records"}),e("span",{class:"field-hint",children:"Names are served authoritatively. Use a fully qualified name such as vm1.lab1.test; a leading *. wildcard is supported. Types: A, AAAA, CNAME, TXT, PTR."})]}),e("button",{class:"button ghost",type:"button",onClick:pe,children:"Add record"})]}),e("div",{class:"acl-rule-rows",children:[r.dnsRecords.map((c,_)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Record ",_+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>ue(_),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"vm1.lab1.test",value:c.name,onInput:B=>ee(_,"name",B.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Type"}),e("select",{value:c.type,onChange:B=>ee(_,"type",B.currentTarget.value),children:[e("option",{value:"A",children:"A"}),e("option",{value:"AAAA",children:"AAAA"}),e("option",{value:"CNAME",children:"CNAME"}),e("option",{value:"TXT",children:"TXT"}),e("option",{value:"PTR",children:"PTR"})]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Value"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:c.type==="A"?"10.20.0.50":c.type==="AAAA"?"fd00:20::50":c.type==="CNAME"||c.type==="PTR"?"vm1.lab1.test":"text",value:c.value,onInput:B=>ee(_,"value",B.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"TTL"}),e("input",{type:"number",min:0,max:86400,placeholder:"300",value:c.ttl??300,onInput:B=>ee(_,"ttl",Number(B.currentTarget.value)||0)})]})]})]},_)),r.dnsRecords.length===0?e("p",{class:"field-hint",children:"No local records yet. Add one, or use this VNF only as a forwarder."}):null]})]})}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.dnsForward,onChange:c=>Y("dnsForward",c.currentTarget.checked)}),e("span",{children:"Forward names without a local record to upstream DNS servers"})]}),e("span",{class:"field-hint",children:"When disabled, unknown names receive NXDOMAIN. Local records always take precedence."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Upstream DNS servers"}),e("input",{type:"text",disabled:!r.dnsForward,autocomplete:"off",spellcheck:!1,placeholder:"1.1.1.1, 8.8.8.8:53",value:r.dnsForwarders,onInput:c=>Y("dnsForwarders",c.currentTarget.value)}),e("span",{class:"field-hint",children:"Comma-separated IPv4/IPv6 addresses or IP:port. Leave empty to use /etc/resolv.conf on the server."})]})]})]}):H?e(ge,{children:e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Transport"}),e("select",{value:r.transport,onChange:c=>Y("transport",c.currentTarget.value),children:[e("option",{value:"memif",children:"memif + gVisor"}),e("option",{value:"tap",children:"VPP tap + kernel TCP"})]}),e("span",{class:"field-hint",children:"TCP MD5 passwords require the tap transport; memif/gVisor does not support TCP MD5."})]}),e("label",{class:"field checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.passive,onChange:c=>Y("passive",c.currentTarget.checked)}),e("span",{children:"Passive mode \u2014 wait for the peer to connect"})]})}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Local AS"}),e("input",{type:"number",required:!0,min:1,value:r.localAS,onInput:c=>Y("localAS",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Router ID"}),e("input",{type:"text",required:!0,placeholder:"10.20.0.1",autocomplete:"off",value:r.routerID,onInput:c=>Y("routerID",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Peer AS"}),e("input",{type:"number",required:!0,min:1,value:r.peerAS,onInput:c=>Y("peerAS",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Peer IPv4/IPv6 address"}),e("input",{type:"text",required:!0,placeholder:"169.254.0.2",autocomplete:"off",value:r.peerIP,onInput:c=>Y("peerIP",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Local IPv4/IPv6 CIDR"}),e("input",{type:"text",required:!0,placeholder:"169.254.0.1/30",autocomplete:"off",value:r.localIP,onInput:c=>Y("localIP",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Peer gateway ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"169.254.0.2 (for routed/multihop peers)",autocomplete:"off",value:r.peerGateway,onInput:c=>Y("peerGateway",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Advertised next hop ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"defaults to local_ip",autocomplete:"off",value:r.nextHop,onInput:c=>Y("nextHop",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Multihop TTL"}),e("input",{type:"number",required:!0,min:1,max:255,value:r.multihopTTL,onInput:c=>Y("multihopTTL",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Target VPP table ID"}),e("input",{type:"number",required:!0,min:0,value:r.targetTableID,onInput:c=>Y("targetTableID",c.currentTarget.value)}),e("span",{class:"field-hint",children:"0 is the default VRF; received IPv4 routes are installed here."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["TCP MD5 password ",e("em",{children:"tap only"})]}),e("input",{type:"password",disabled:r.transport==="memif",placeholder:r.transport==="memif"?"Unavailable on memif":"optional",autocomplete:"new-password",value:r.password,onInput:c=>Y("password",c.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Advertised IPv4 prefixes ",e("em",{children:"optional"})]}),e("textarea",{rows:4,placeholder:`10.10.0.0/16 -10.20.0.0/24`,spellcheck:!1,value:r.advertisePrefixes,onInput:c=>Y("advertisePrefixes",c.currentTarget.value)}),e("span",{class:"field-hint",children:"One IPv4 CIDR per line. These prefixes are advertised to the peer."})]})]})}):e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Outside NIC"}),e("select",{required:!0,value:r.outsideInterface,onChange:c=>Y("outsideInterface",c.currentTarget.value),children:[e("option",{value:"",disabled:!0,children:k?"Loading host interfaces\u2026":"Select a wired physical NIC\u2026"}),w.map(c=>{let _=c.default_route||c.physical===!1||c.wireless,B=Array.isArray(c.addresses)&&c.addresses.length?` \u2014 ${c.addresses.join(", ")}`:"",X=c.default_route?" (default route \u2014 blocked)":c.physical===!1?" (virtual \u2014 not selectable)":c.wireless?" (wireless \u2014 not supported)":"";return e("option",{value:c.name,disabled:_,children:[c.name,B,X]},c.name)}),r.outsideInterface&&!w.some(c=>c.name===r.outsideInterface)?e("option",{value:r.outsideInterface,children:[r.outsideInterface," (not currently present)"]}):null]}),e("span",{class:"field-hint",children:er(w,r.outsideInterface)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Outside address ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"empty = VPP DHCP client",autocomplete:"off",spellcheck:!1,value:r.outsideAddress,onInput:c=>Y("outsideAddress",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Outside gateway ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"198.51.100.1",autocomplete:"off",spellcheck:!1,value:r.outsideGateway,onInput:c=>Y("outsideGateway",c.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["NAT44 pool ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"203.0.113.10-203.0.113.20",autocomplete:"off",spellcheck:!1,value:r.nat44Pool,onInput:c=>Y("nat44Pool",c.currentTarget.value)})]}),e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["DNAT / port forwarding ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:"Expose services inside this network on the NAT44 outside address. Saving restarts NAT44."})]}),e("button",{class:"button ghost",type:"button",onClick:E,children:"Add rule"})]}),e("div",{class:"acl-rule-rows",children:r.dnatRules.map((c,_)=>e("div",{class:"acl-rule dnat-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Rule ",_+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>se(_),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dnat-field-protocol",children:[e("span",{class:"field-label",children:"Protocol"}),e("select",{value:c.protocol,onChange:B=>M(_,"protocol",B.currentTarget.value),children:[e("option",{value:"tcp",children:"TCP"}),e("option",{value:"udp",children:"UDP"})]})]}),e("label",{class:"field dnat-field-external-port",children:[e("span",{class:"field-label",children:"External port"}),e("input",{type:"number",min:1,max:65535,step:1,required:!0,inputMode:"numeric",placeholder:"8080",value:c.external_port||"",onInput:B=>M(_,"external_port",Number(B.currentTarget.value))})]}),e("label",{class:"field dnat-field-local-ip",children:[e("span",{class:"field-label",children:"Internal address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,inputMode:"decimal",placeholder:"10.20.0.100",value:c.local_ip,onInput:B=>M(_,"local_ip",B.currentTarget.value)})]}),e("label",{class:"field dnat-field-local-port",children:[e("span",{class:"field-label",children:"Internal port"}),e("input",{type:"number",min:1,max:65535,step:1,required:!0,inputMode:"numeric",placeholder:"80",value:c.local_port||"",onInput:B=>M(_,"local_port",Number(B.currentTarget.value))})]})]})]},_))})]})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.enabled,disabled:!p&&!l,onChange:c=>Y("enabled",c.currentTarget.checked)}),e("span",{children:"Enable this VNF"})]}),e("span",{class:"field-hint",children:me})]}),m?e("p",{class:"form-error",role:"alert",children:m}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:A,onClick:y,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:A,children:A?"Saving\u2026":p?"Save VNF":"Add VNF"})]})]})})}function ar(t){return t.replace(/^SESSION_STATE_/,"").toLowerCase()}function Ka({vnf:t,onClose:n}){let[s,l]=d(null),[a,o]=d(""),[f,r]=d(!1),b=te(null),w=async()=>{r(!0);try{let h=await V(`/api/vnfs/${t.id}/bgp`);l(h),o("")}catch(h){o(h instanceof Error?h.message:String(h))}finally{r(!1)}};z(()=>{b.current?.showModal(),w()},[]);let u=s?.session_state||"",k=s?s.established?"established":s.running?ar(u)||"starting":"not running":"loading";return e("dialog",{ref:b,class:"vnf-bgp-dialog","aria-labelledby":"vnf-bgp-title",onClose:n,onClick:h=>{h.target===h.currentTarget&&b.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"BGP monitor"}),e("h2",{id:"vnf-bgp-title",children:["BGP status \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>b.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 local AS ",t.config.local_as||"\u2014"," \xB7 peer AS"," ",t.config.peer_as||"\u2014"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,s?e(ge,{children:[e("div",{class:"bgp-summary",children:[e("span",{class:"status-badge","data-status":s.established?"running":s.running?"degraded":"disabled",children:k}),e("span",{class:"bgp-peer",children:["peer ",s.peer_address||t.config.peer_ip||"\u2014",s.peer_as?` \xB7 AS${s.peer_as}`:""]}),e("span",{class:"bgp-local",children:["local AS",s.local_as||"\u2014"]})]}),e("h3",{class:"bgp-section-title",children:"Advertised prefixes"}),e("div",{class:"table-wrap",children:s.advertised&&s.advertised.length>0?e("table",{children:[e("thead",{children:e("tr",{children:e("th",{scope:"col",children:"Prefix"})})}),e("tbody",{children:s.advertised.map(h=>e("tr",{children:e("td",{class:"mono",children:h})},h))})]}):e("div",{class:"empty",children:e("p",{children:"No advertised prefixes."})})}),e("h3",{class:"bgp-section-title",children:"Received prefixes"}),e("div",{class:"table-wrap",children:s.received&&s.received.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Next hop"}),e("th",{scope:"col",children:"AS path"})]})}),e("tbody",{children:s.received.map(h=>e("tr",{children:[e("td",{class:"mono",children:h.prefix}),e("td",{class:"mono",children:h.next_hop||"\u2014"}),e("td",{class:"mono",children:h.as_path&&h.as_path.length>0?h.as_path.join(" "):"\u2014"})]},h.prefix))})]}):e("div",{class:"empty",children:e("p",{children:"No received prefixes."})})})]}):e("p",{class:"dialog-lede",children:f?"Loading\u2026":"BGP runtime not running."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>b.current?.close(),children:"Close"})]})]})}function or(t,n){return t==="static"?"static":t==="dhcp"?n?`DHCP #${n}`:"DHCP":t||"\u2014"}function Ja({vnf:t,onClose:n}){let[s,l]=d(null),[a,o]=d(""),[f,r]=d(!1),b=te(null),w=async()=>{r(!0);try{let k=await V(`/api/vnfs/${t.id}/dns-records`);l(k),o("")}catch(k){o(k instanceof Error?k.message:String(k))}finally{r(!1)}};z(()=>{b.current?.showModal(),w()},[]);let u=s?.records||[];return e("dialog",{ref:b,class:"vnf-bgp-dialog","aria-labelledby":"vnf-dns-records-title",onClose:n,onClick:k=>{k.target===k.currentTarget&&b.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"DNS records"}),e("h2",{id:"vnf-dns-records-title",children:["DNS records \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>b.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 ",u.length," record",u.length===1?"":"s"," \xB7 static and DHCP-derived"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,s?.dynamic_error?e("div",{class:"banner",role:"alert",children:["Dynamic records unavailable: ",s.dynamic_error]}):null,e("div",{class:"table-wrap",children:u.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Value"}),e("th",{scope:"col",children:"TTL"}),e("th",{scope:"col",children:"Source"})]})}),e("tbody",{children:u.map((k,h)=>e("tr",{children:[e("td",{class:"mono",children:k.name}),e("td",{class:"mono",children:k.type}),e("td",{class:"mono",children:k.value}),e("td",{class:"mono",children:k.ttl||"\u2014"}),e("td",{children:or(k.source,k.source_vnf_id)})]},`${k.name}-${k.type}-${k.value}-${h}`))})]}):e("div",{class:"empty",children:e("p",{children:f?"Loading records\u2026":"No DNS records."})})}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>b.current?.close(),children:"Close"})]})]})}function Xa({vnf:t,onClose:n}){let[s,l]=d([]),[a,o]=d(""),[f,r]=d(!1),b=te(null),w=async()=>{r(!0);try{let u=await V(`/api/vnfs/${t.id}/leases`);l(Array.isArray(u.leases)?u.leases:[]),o("")}catch(u){o(u instanceof Error?u.message:String(u))}finally{r(!1)}};return z(()=>{b.current?.showModal(),w()},[]),e("dialog",{ref:b,class:"vnf-leases-dialog","aria-labelledby":"vnf-leases-title",onClose:n,onClick:u=>{u.target===u.currentTarget&&b.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Lease monitor"}),e("h2",{id:"vnf-leases-title",children:["Leases \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>b.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.type==="dhcpv6"?"DHCPv6":"DHCPv4"," \xB7 ",t.network_name||"network"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Client ID"}),e("th",{scope:"col",children:"Address"}),e("th",{scope:"col",children:"Hostname"}),e("th",{scope:"col",children:"Expires"}),e("th",{scope:"col",children:"Active"})]})}),e("tbody",{children:s.map(u=>e("tr",{children:[e("td",{class:"mono",children:u.client_id||"\u2014"}),e("td",{class:"mono",children:u.address||"\u2014"}),e("td",{children:u.hostname||"\u2014"}),e("td",{children:Wt(u.expires_at)}),e("td",{children:e("span",{class:"status-badge","data-status":u.active?"running":"disabled",children:u.active?"active":"expired"})})]},`${u.client_id}-${u.address}`))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No leases yet"}),e("p",{children:"Clients will appear here after they receive an address from this VNF."})]})}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>b.current?.close(),children:"Close"})]})]})}function Za({vnf:t,onClose:n}){let[s,l]=d([]),[a,o]=d(""),[f,r]=d(!1),b=te(null),w=async()=>{r(!0);try{let u=await V(`/api/vnfs/${t.id}/sessions`);l(Array.isArray(u.sessions)?u.sessions:[]),o("")}catch(u){o(u instanceof Error?u.message:String(u))}finally{r(!1)}};return z(()=>{b.current?.showModal(),w()},[]),e("dialog",{ref:b,class:"vnf-sessions-dialog","aria-labelledby":"vnf-sessions-title",onClose:n,onClick:u=>{u.target===u.currentTarget&&b.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"NAT monitor"}),e("h2",{id:"vnf-sessions-title",children:["NAT44 sessions \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>b.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 outside ",t.config.outside_interface||"\u2014"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,e("pre",{class:"nat-sessions",children:s.length>0?s.join(` -`):f?"Loading\u2026":"No active NAT44 sessions."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>b.current?.close(),children:"Close"})]})]})}function ja(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d(!1),[f,r]=d(!0),[b,w]=d(""),[u,k]=d(null),[h,A]=d(null),[I,m]=d(null),[g,P]=d(null),[L,S]=d(null),N=Ne("vnf"),p=ze();z(()=>{if(p.segments[0]!=="vnfs"){k(null),A(null),m(null),P(null),S(null);return}let E=p.segments[1];if(E==="new"){let M=Number(p.query.get("network"))||void 0;A(null),m(null),P(null),S(null),k({vnf:null,networkID:M});return}if(E&&E!=="new"){let M=t.find(ce=>String(ce.id)===E),se=p.segments[2];M&&(k(se==="edit"?{vnf:M}:null),A(se==="leases"?M:null),m(se==="sessions"?M:null),P(se==="bgp"?M:null),S(se==="dns"?M:null));return}k(null),A(null),m(null),P(null),S(null)},[p.path,p.search,t]);let T=E=>{n(E),window.dispatchEvent(new CustomEvent("rookery:vnfs-changed",{detail:E}))},H=async(E=!0)=>{try{let M=await V("/api/vnfs");T(Array.isArray(M)?M:[]),E&&w("")}catch(M){E&&w(M instanceof Error?M.message:String(M))}},G=async()=>{try{let E=await V("/api/networks");l(Array.isArray(E)?E:[])}catch{l([])}},J=async()=>{try{let E=await V("/api/status");o(!!E.vpp?.ready)}catch{o(!1)}},ne=async()=>{r(!0),await Promise.allSettled([H(),G(),J()]),r(!1)};z(()=>{ne();let E=C=>{let U=C.detail;Array.isArray(U)?l(U):G(),H(!1)},M=C=>{o(!!C.detail)},se=C=>{let U=C.detail;Array.isArray(U)&&n(U)},ce=C=>{let U=C.detail?.networkId;Q(U?`/vnfs/new?network=${U}`:"/vnfs/new")},v=()=>{G()},$=C=>{C.detail!=="vnfs"&&(k(null),A(null),m(null),S(null))};return window.addEventListener("rookery:networks-changed",E),window.addEventListener("rookery:vpp-ready",M),window.addEventListener("rookery:vnfs-data",se),window.addEventListener("rookery:add-vnf",ce),window.addEventListener("rookery:refresh-networks",v),window.addEventListener("rookery:tab-changed",$),()=>{window.removeEventListener("rookery:networks-changed",E),window.removeEventListener("rookery:vpp-ready",M),window.removeEventListener("rookery:vnfs-data",se),window.removeEventListener("rookery:add-vnf",ce),window.removeEventListener("rookery:refresh-networks",v),window.removeEventListener("rookery:tab-changed",$)}},[]),z(()=>{let E=document.getElementById("vnf-tab-count");E&&(E.textContent=String(t.length))},[t.length]);let x=()=>{window.dispatchEvent(new CustomEvent("rookery:refresh-networks"))},R=E=>{k(null),Q("/vnfs",!0),w(""),oe(`${u?.vnf?"Saved":"Added"} VNF ${E.name}`),H(!1),(E.type==="nat44"||E.type==="dns")&&x()},F=async E=>{try{await V(`/api/vnfs/${E.id}/restart`,{method:"POST"}),w(""),oe(`Started VNF ${E.name}`),H(!1)}catch(M){let se=M instanceof Error?M.message:String(M);w(`Could not start ${E.name}: ${se}`),M instanceof Ze&&M.status===503&&J()}},W=async E=>{if(window.confirm(E.type==="nat44"?`Delete VNF "${E.name}"? +VPP will own this NIC and its host IPv4/IPv6 addresses will be flushed. The host default-route interface is rejected by the server.`))return;let I=r.advertisePrefixes.split(/[\n,]+/).map(Z=>Z.trim()).filter(Boolean),U=R?{transport:r.transport==="tap"?"tap":"memif",local_as:Number(r.localAS),router_id:r.routerID.trim(),peer_as:Number(r.peerAS),peer_ip:r.peerIP.trim(),local_ip:r.localIP.trim(),peer_gateway:r.peerGateway.trim(),next_hop:r.nextHop.trim(),multihop_ttl:Number(r.multihopTTL),password:r.transport==="tap"?r.password:"",passive:r.passive,target_table_id:Number(r.targetTableID),advertise_prefixes:I}:H?{dns_records:r.dnsRecords.map(Z=>({name:Z.name.trim(),type:Z.type,value:Z.value.trim(),ttl:Number(Z.ttl)||0})),dns_forward:r.dnsForward,dns_forwarders:r.dnsForwarders.split(/[\n,]+/).map(Z=>Z.trim()).filter(Boolean)}:_?{gateway:r.gateway.trim(),outside_interface:r.outsideInterface,outside_address:r.outsideAddress.trim(),outside_gateway:r.outsideGateway.trim(),nat44_pool:r.nat44Pool.trim(),dnat_rules:r.dnatRules.map(Z=>({protocol:Z.protocol,external_port:Number(Z.external_port),local_ip:Z.local_ip.trim(),local_port:Number(Z.local_port)}))}:{gateway:r.gateway.trim(),range_start:r.rangeStart.trim(),range_end:r.rangeEnd.trim(),dns:r.dns.trim(),lease_time:r.leaseTime.trim()||"12h",dhcp_options:r.dhcpOptions.map(Z=>({code:Number(Z.code),format:Z.format,value:Z.value.trim()})),...r.transport==="shared"||r.transport==="dedicated"||r.transport==="tap"?{transport:r.transport}:{},...r.type==="dhcpv4"?{static_assignments:r.staticAssignments.map(Z=>({mac:Z.mac.trim(),ip:Z.ip.trim()}))}:{},...r.dnsVNFID>0?{dns_vnf_id:r.dnsVNFID,dns_domain:r.dnsDomain.trim(),dns_ttl:Number(r.dnsTTL)||300}:{}},Q={name:r.name.trim(),enabled:r.enabled,config:U};b||(Q.network_id=r.networkID,Q.type=r.type),T(!0);try{let Z=await M(b?`/api/vnfs/${t?.id}`:"/api/vnfs",{method:b?"PUT":"POST",body:JSON.stringify(Q)});await Ee("vnf",Z.id,Z.name,C),f(Z)}catch(Z){y(Z instanceof Error?Z.message:String(Z))}finally{T(!1)}},p=()=>{g(u=>({...u,dnatRules:[...u.dnatRules,{protocol:"tcp",external_port:8080,local_ip:"",local_port:80}]}))},N=(u,I,U)=>{g(Q=>({...Q,dnatRules:Q.dnatRules.map((Z,q)=>q===u?{...Z,[I]:U}:Z)}))},X=u=>{g(I=>({...I,dnatRules:I.dnatRules.filter((U,Q)=>Q!==u)}))},ae=()=>{g(u=>({...u,staticAssignments:[...u.staticAssignments,{mac:"",ip:""}]}))},$=(u,I,U)=>{g(Q=>({...Q,staticAssignments:Q.staticAssignments.map((Z,q)=>q===u?{...Z,[I]:U}:Z)}))},S=u=>{g(I=>({...I,staticAssignments:I.staticAssignments.filter((U,Q)=>Q!==u)}))},v=()=>{g(u=>({...u,dhcpOptions:[...u.dhcpOptions,{code:0,format:"string",value:""}]}))},V=(u,I,U)=>{g(Q=>({...Q,dhcpOptions:Q.dhcpOptions.map((Z,q)=>q===u?{...Z,[I]:U}:Z)}))},ie=(u,I)=>{let U=In(r.type,I);g(Q=>({...Q,dhcpOptions:Q.dhcpOptions.map((Z,q)=>q===u?{...Z,code:I,...U?{format:U.format}:{}}:Z)}))},pe=u=>{g(I=>({...I,dhcpOptions:I.dhcpOptions.filter((U,Q)=>Q!==u)}))},fe=()=>{g(u=>({...u,dnsRecords:[...u.dnsRecords,{name:"",type:"A",value:"",ttl:300}]}))},ne=(u,I,U)=>{g(Q=>({...Q,dnsRecords:Q.dnsRecords.map((Z,q)=>q===u?{...Z,[I]:U}:Z)}))},me=u=>{g(I=>({...I,dnsRecords:I.dnsRecords.filter((U,Q)=>Q!==u)}))},ue=R?c?r.transport==="memif"?"Creates a memif and starts the embedded BGP speaker on gVisor. TCP MD5 is unavailable on this transport.":"Creates a VPP tap and starts the embedded BGP speaker on kernel TCP; TCP MD5 is supported.":"VPP is not ready. The BGP VNF can be created disabled and started later.":H?c?"Starts a DNS server on the network service address (.2, UDP/TCP 53) using the shared VNF memif.":"VPP is not ready. The DNS VNF can be created disabled and started later.":_?c?r.enabled?"Saving takes over the selected outside NIC and flushes its host addresses.":"The configuration is saved without taking over the outside NIC.":"VPP is not ready. Save disabled and start the NAT44 VNF later.":c?"Creates a VPP tap and starts the function immediately.":"VPP is not ready. The VNF can be created disabled and started later.";return e("div",{class:"vnf-page",children:e("form",{ref:A,class:"vnf-page-form",novalidate:!0,onSubmit:u=>{ge(u)},children:[e("header",{class:"route-page-head",children:e("div",{children:[e("button",{class:"back-link",type:"button",disabled:L,onClick:se,children:"\u2190 VNFs"}),e("p",{class:"eyebrow",children:"Network function"}),e("h2",{id:"vnf-dialog-title",children:b?`Edit ${t?.name??"VNF"}`:"Add VNF"})]})}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:P,type:"text",required:!0,maxlength:64,pattern:Qi,autocomplete:"off",spellcheck:!1,placeholder:"lab-dhcpv4",value:r.name,onInput:u=>J("name",u.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Network"}),e("select",{required:!0,disabled:b,value:String(r.networkID),onChange:u=>{let I=Number(u.currentTarget.value),U=n.find(Q=>Q.id===I);g(Q=>({...Q,networkID:I,dnsVNFID:s.some(Z=>Z.id===Q.dnsVNFID&&Z.type==="dns"&&Z.network_id===I)?Q.dnsVNFID:0,gateway:Q.type==="dhcpv6"?Q.gateway:Dn(U?.ipv4_cidr)||Q.gateway}))},children:n.map(u=>e("option",{value:String(u.id),children:[u.name," \xB7 bd ",u.bridge_domain_id]},u.id))})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Type"}),e("select",{required:!0,disabled:b,value:r.type,onChange:u=>{if(!b){let I=u.currentTarget.value;g(U=>{let Q=nr(U,I),Z=n.find(q=>q.id===Q.networkID);return(I==="dhcpv4"||I==="nat44")&&Z?.ipv4_cidr&&(Q.gateway=Dn(Z.ipv4_cidr)||Q.gateway),Q})}},children:[e("option",{value:"dhcpv4",children:"DHCPv4"}),e("option",{value:"dhcpv6",children:"DHCPv6"}),e("option",{value:"nat44",children:"NAT44"}),e("option",{value:"bgp",children:"BGP"}),e("option",{value:"dns",children:"DNS"})]}),e("span",{class:"field-hint",children:ar(r.type)})]}),!R&&!H?e("label",{class:"field",children:[e("span",{class:"field-label",children:"Gateway CIDR"}),e("input",{type:"text",required:!0,placeholder:_?"10.20.0.1/24 (network BVI gateway)":r.type==="dhcpv6"?"fd00:20::1/64":"10.20.0.1/24",autocomplete:"off",spellcheck:!1,value:r.gateway,onInput:u=>J("gateway",u.currentTarget.value)}),e("span",{class:"field-hint",children:["Network range: ",n.find(u=>u.id===r.networkID)?.ipv4_cidr||"not set"]})]}):null]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:e(Le,{labels:C,onChange:E,disabled:L})})}),!_&&!R&&!H?e(ye,{children:[e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:["DNS servers ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:r.type==="dhcpv6"?"2001:4860:4860::8888":"1.1.1.1,8.8.8.8",autocomplete:"off",spellcheck:!1,value:r.dns,onInput:u=>J("dns",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Range start"}),e("input",{type:"text",required:!0,placeholder:r.type==="dhcpv6"?"fd00:20::100":"10.20.0.100",autocomplete:"off",spellcheck:!1,value:r.rangeStart,onInput:u=>J("rangeStart",u.currentTarget.value)})]}),r.type==="dhcpv4"?e("label",{class:"field",children:[e("span",{class:"field-label",children:"VNF networking"}),e("select",{value:r.transport,onChange:u=>J("transport",u.currentTarget.value),children:[e("option",{value:"inherit",children:"Inherit network default (shared)"}),e("option",{value:"shared",children:["Shared network memif",B?` \xB7 ${B}`:""]}),e("option",{value:"dedicated",children:["Dedicated memif",z?` \xB7 ${z}`:""]}),e("option",{value:"tap",children:["Tap",z?` \xB7 ${z}`:""]})]}),e("span",{class:"field-hint",children:r.transport==="shared"?`The VNF uses the shared ${B||".2 service"} address; the BVI owns the gateway (.1).`:r.transport==="dedicated"?`The VNF uses ${z}; on a NAT network the server may move it to the .2 service address.`:r.transport==="tap"?`The tap is assigned ${z}; on a NAT network the server may move it to the .2 service address.`:`Inherits the network default (${G?.vnf_transport||"shared"}). Shared uses ${B||".2"}.`})]}):null,e("label",{class:"field",children:[e("span",{class:"field-label",children:"Range end"}),e("input",{type:"text",required:!0,placeholder:r.type==="dhcpv6"?"fd00:20::200":"10.20.0.200",autocomplete:"off",spellcheck:!1,value:r.rangeEnd,onInput:u=>J("rangeEnd",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Lease time"}),e("input",{type:"text",placeholder:"12h",autocomplete:"off",spellcheck:!1,value:r.leaseTime,onInput:u=>J("leaseTime",u.currentTarget.value)})]})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["DHCP DNS integration ",e("em",{children:"optional"})]}),e("select",{value:String(r.dnsVNFID),onChange:u=>{let I=Number(u.currentTarget.value),U=r.type==="dhcpv6"?x:re;g(Q=>({...Q,dnsVNFID:I,...I>0&&U?{dns:U}:{}}))},children:[e("option",{value:"0",children:"No DNS integration"}),F.map(u=>e("option",{value:String(u.id),children:u.name},u.id))]}),e("span",{class:"field-hint",children:F.length===0?"Create a DNS VNF on this network first to register DHCP leases.":"Active leases get A/AAAA and PTR records in the selected DNS VNF. The DNS server field is set to its service address (.2)."})]}),r.dnsVNFID>0?e(ye,{children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"DNS domain"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"lab1.test",value:r.dnsDomain,onInput:u=>J("dnsDomain",u.currentTarget.value)}),e("span",{class:"field-hint",children:"Used when the client does not send a FQDN."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"DNS TTL (seconds)"}),e("input",{type:"number",min:1,max:86400,value:r.dnsTTL,onInput:u=>J("dnsTTL",u.currentTarget.value)})]})]}):null]}),r.type==="dhcpv4"?e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["Static assignments ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:"Reserve an IPv4 address for a MAC address. Reserved addresses are excluded from the dynamic pool and can be inside or outside the DHCP range."})]}),e("button",{class:"button ghost",type:"button",onClick:ae,children:"Add assignment"})]}),e("div",{class:"acl-rule-rows",children:r.staticAssignments.map((u,I)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Assignment ",I+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>S(I),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dhcp-static-field",children:[e("span",{class:"field-label",children:"MAC address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"02:00:00:00:00:01",value:u.mac,onInput:U=>$(I,"mac",U.currentTarget.value)})]}),e("label",{class:"field dhcp-static-field",children:[e("span",{class:"field-label",children:"IP address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,inputMode:"decimal",placeholder:"10.20.0.50",value:u.ip,onInput:U=>$(I,"ip",U.currentTarget.value)})]})]})]},I))})]})}):null,e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["Extra DHCP options ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:["Advertise additional options such as option 15 (domain name), 42 (NTP), 119 (domain search), or DHCPv6 option 24 (domain search). Use",e("code",{children:"string"}),", ",e("code",{children:"ipv4"}),", ",e("code",{children:"ipv6"}),", ",e("code",{children:"hex"}),",",e("code",{children:"uint8"}),", ",e("code",{children:"uint16"}),", ",e("code",{children:"uint32"}),", or",e("code",{children:"domain-list"}),". DNS server options are set through the DNS field."]})]}),e("button",{class:"button ghost",type:"button",onClick:v,children:"Add option"})]}),e("div",{class:"acl-rule-rows",children:[r.dhcpOptions.map((u,I)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Option ",I+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>pe(I),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dhcp-option-code",children:[e("span",{class:"field-label",children:"Code"}),e("input",{type:"number",required:!0,min:1,max:r.type==="dhcpv6"?65535:254,list:`dhcp-option-codes-${r.type}`,value:u.code||"",onInput:U=>ie(I,Number(U.currentTarget.value))}),(()=>{let U=In(r.type,u.code);return e("span",{class:"field-hint",children:U?`${U.label}${U.hint?` \u2014 ${U.hint}`:""}`:"Custom option code"})})()]}),e("label",{class:"field dhcp-option-format",children:[e("span",{class:"field-label",children:"Format"}),e("select",{value:u.format,onChange:U=>V(I,"format",U.currentTarget.value),children:[e("option",{value:"string",children:"string"}),e("option",{value:"ipv4",children:"ipv4"}),e("option",{value:"ipv6",children:"ipv6"}),e("option",{value:"hex",children:"hex"}),e("option",{value:"uint8",children:"uint8"}),e("option",{value:"uint16",children:"uint16"}),e("option",{value:"uint32",children:"uint32"}),e("option",{value:"domain-list",children:"domain-list"})]})]}),e("label",{class:"field dhcp-option-value",children:[e("span",{class:"field-label",children:"Value"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:In(r.type,u.code)?.placeholder??(u.format==="ipv4"?"10.20.0.1, 10.20.0.2":u.format==="ipv6"?"fd00:20::1":u.format==="domain-list"?"lab.test, apps.lab.test":u.format==="hex"?"01:02:ff":"lab.test"),value:u.value,onInput:U=>V(I,"value",U.currentTarget.value)})]})]})]},I)),r.dhcpOptions.length===0?e("p",{class:"field-hint",children:"No extra options. Example: code 15, format string, value lab.test advertises the domain name. DNS and router options override the DNS field and gateway."}):null]}),e("datalist",{id:`dhcp-option-codes-${r.type}`,children:ja(r.type).map(u=>e("option",{value:u.code,label:`${u.label} (${u.format})`}))})]})})]}):H?e(ye,{children:[e("p",{class:"field-hint",children:["DHCPv4 clients can use ",e("code",{children:re||"the IPv4 .2 service address"}),"; DHCPv6 clients can use ",e("code",{children:x||"the IPv6 .2 service address"}),". Set that address in the DHCP VNF's DNS field (or configure it directly on clients)."]}),e("div",{class:"form-grid",children:e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:"DNS records"}),e("span",{class:"field-hint",children:"Names are served authoritatively. Use a fully qualified name such as vm1.lab1.test; a leading *. wildcard is supported. Types: A, AAAA, CNAME, TXT, PTR."})]}),e("button",{class:"button ghost",type:"button",onClick:fe,children:"Add record"})]}),e("div",{class:"acl-rule-rows",children:[r.dnsRecords.map((u,I)=>e("div",{class:"acl-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Record ",I+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>me(I),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Name"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:"vm1.lab1.test",value:u.name,onInput:U=>ne(I,"name",U.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Type"}),e("select",{value:u.type,onChange:U=>ne(I,"type",U.currentTarget.value),children:[e("option",{value:"A",children:"A"}),e("option",{value:"AAAA",children:"AAAA"}),e("option",{value:"CNAME",children:"CNAME"}),e("option",{value:"TXT",children:"TXT"}),e("option",{value:"PTR",children:"PTR"})]})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Value"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,placeholder:u.type==="A"?"10.20.0.50":u.type==="AAAA"?"fd00:20::50":u.type==="CNAME"||u.type==="PTR"?"vm1.lab1.test":"text",value:u.value,onInput:U=>ne(I,"value",U.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"TTL"}),e("input",{type:"number",min:0,max:86400,placeholder:"300",value:u.ttl??300,onInput:U=>ne(I,"ttl",Number(U.currentTarget.value)||0)})]})]})]},I)),r.dnsRecords.length===0?e("p",{class:"field-hint",children:"No local records yet. Add one, or use this VNF only as a forwarder."}):null]})]})}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.dnsForward,onChange:u=>J("dnsForward",u.currentTarget.checked)}),e("span",{children:"Forward names without a local record to upstream DNS servers"})]}),e("span",{class:"field-hint",children:"When disabled, unknown names receive NXDOMAIN. Local records always take precedence."})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Upstream DNS servers"}),e("input",{type:"text",disabled:!r.dnsForward,autocomplete:"off",spellcheck:!1,placeholder:"1.1.1.1, 8.8.8.8:53",value:r.dnsForwarders,onInput:u=>J("dnsForwarders",u.currentTarget.value)}),e("span",{class:"field-hint",children:"Comma-separated IPv4/IPv6 addresses or IP:port. Leave empty to use /etc/resolv.conf on the server."})]})]})]}):R?e(ye,{children:e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Transport"}),e("select",{value:r.transport,onChange:u=>J("transport",u.currentTarget.value),children:[e("option",{value:"memif",children:"memif + gVisor"}),e("option",{value:"tap",children:"VPP tap + kernel TCP"})]}),e("span",{class:"field-hint",children:"TCP MD5 passwords require the tap transport; memif/gVisor does not support TCP MD5."})]}),e("label",{class:"field checkbox-field",children:e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.passive,onChange:u=>J("passive",u.currentTarget.checked)}),e("span",{children:"Passive mode \u2014 wait for the peer to connect"})]})}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Local AS"}),e("input",{type:"number",required:!0,min:1,value:r.localAS,onInput:u=>J("localAS",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Router ID"}),e("input",{type:"text",required:!0,placeholder:"10.20.0.1",autocomplete:"off",value:r.routerID,onInput:u=>J("routerID",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Peer AS"}),e("input",{type:"number",required:!0,min:1,value:r.peerAS,onInput:u=>J("peerAS",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Peer IPv4/IPv6 address"}),e("input",{type:"text",required:!0,placeholder:"169.254.0.2",autocomplete:"off",value:r.peerIP,onInput:u=>J("peerIP",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Local IPv4/IPv6 CIDR"}),e("input",{type:"text",required:!0,placeholder:"169.254.0.1/30",autocomplete:"off",value:r.localIP,onInput:u=>J("localIP",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Peer gateway ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"169.254.0.2 (for routed/multihop peers)",autocomplete:"off",value:r.peerGateway,onInput:u=>J("peerGateway",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Advertised next hop ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"defaults to local_ip",autocomplete:"off",value:r.nextHop,onInput:u=>J("nextHop",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Multihop TTL"}),e("input",{type:"number",required:!0,min:1,max:255,value:r.multihopTTL,onInput:u=>J("multihopTTL",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Target VPP table ID"}),e("input",{type:"number",required:!0,min:0,value:r.targetTableID,onInput:u=>J("targetTableID",u.currentTarget.value)}),e("span",{class:"field-hint",children:"0 is the default VRF; received IPv4 routes are installed here."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["TCP MD5 password ",e("em",{children:"tap only"})]}),e("input",{type:"password",disabled:r.transport==="memif",placeholder:r.transport==="memif"?"Unavailable on memif":"optional",autocomplete:"new-password",value:r.password,onInput:u=>J("password",u.currentTarget.value)})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Advertised IPv4 prefixes ",e("em",{children:"optional"})]}),e("textarea",{rows:4,placeholder:`10.10.0.0/16 +10.20.0.0/24`,spellcheck:!1,value:r.advertisePrefixes,onInput:u=>J("advertisePrefixes",u.currentTarget.value)}),e("span",{class:"field-hint",children:"One IPv4 CIDR per line. These prefixes are advertised to the peer."})]})]})}):e("div",{class:"form-grid",children:[e("label",{class:"field",children:[e("span",{class:"field-label",children:"Outside NIC"}),e("select",{required:!0,value:r.outsideInterface,onChange:u=>J("outsideInterface",u.currentTarget.value),children:[e("option",{value:"",disabled:!0,children:k?"Loading host interfaces\u2026":"Select a wired physical NIC\u2026"}),w.map(u=>{let I=u.default_route||u.physical===!1||u.wireless,U=Array.isArray(u.addresses)&&u.addresses.length?` \u2014 ${u.addresses.join(", ")}`:"",Q=u.default_route?" (default route \u2014 blocked)":u.physical===!1?" (virtual \u2014 not selectable)":u.wireless?" (wireless \u2014 not supported)":"";return e("option",{value:u.name,disabled:I,children:[u.name,U,Q]},u.name)}),r.outsideInterface&&!w.some(u=>u.name===r.outsideInterface)?e("option",{value:r.outsideInterface,children:[r.outsideInterface," (not currently present)"]}):null]}),e("span",{class:"field-hint",children:sr(w,r.outsideInterface)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Outside address ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"empty = VPP DHCP client",autocomplete:"off",spellcheck:!1,value:r.outsideAddress,onInput:u=>J("outsideAddress",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["Outside gateway ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"198.51.100.1",autocomplete:"off",spellcheck:!1,value:r.outsideGateway,onInput:u=>J("outsideGateway",u.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:["NAT44 pool ",e("em",{children:"optional"})]}),e("input",{type:"text",placeholder:"203.0.113.10-203.0.113.20",autocomplete:"off",spellcheck:!1,value:r.nat44Pool,onInput:u=>J("nat44Pool",u.currentTarget.value)})]}),e("div",{class:"field field-wide",children:[e("div",{class:"panel-head acl-rules-head",children:[e("div",{children:[e("span",{class:"field-label",children:["DNAT / port forwarding ",e("em",{children:"optional"})]}),e("span",{class:"field-hint",children:"Expose services inside this network on the NAT44 outside address. Saving restarts NAT44."})]}),e("button",{class:"button ghost",type:"button",onClick:p,children:"Add rule"})]}),e("div",{class:"acl-rule-rows",children:r.dnatRules.map((u,I)=>e("div",{class:"acl-rule dnat-rule",children:[e("div",{class:"acl-rule-head",children:[e("span",{class:"dnat-rule-title",children:["Rule ",I+1]}),e("button",{class:"row-action danger",type:"button",onClick:()=>X(I),children:"Remove"})]}),e("div",{class:"acl-rule-fields",children:[e("label",{class:"field dnat-field-protocol",children:[e("span",{class:"field-label",children:"Protocol"}),e("select",{value:u.protocol,onChange:U=>N(I,"protocol",U.currentTarget.value),children:[e("option",{value:"tcp",children:"TCP"}),e("option",{value:"udp",children:"UDP"})]})]}),e("label",{class:"field dnat-field-external-port",children:[e("span",{class:"field-label",children:"External port"}),e("input",{type:"number",min:1,max:65535,step:1,required:!0,inputMode:"numeric",placeholder:"8080",value:u.external_port||"",onInput:U=>N(I,"external_port",Number(U.currentTarget.value))})]}),e("label",{class:"field dnat-field-local-ip",children:[e("span",{class:"field-label",children:"Internal address"}),e("input",{type:"text",required:!0,autocomplete:"off",spellcheck:!1,inputMode:"decimal",placeholder:"10.20.0.100",value:u.local_ip,onInput:U=>N(I,"local_ip",U.currentTarget.value)})]}),e("label",{class:"field dnat-field-local-port",children:[e("span",{class:"field-label",children:"Internal port"}),e("input",{type:"number",min:1,max:65535,step:1,required:!0,inputMode:"numeric",placeholder:"80",value:u.local_port||"",onInput:U=>N(I,"local_port",Number(U.currentTarget.value))})]})]})]},I))})]})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:r.enabled,disabled:!b&&!c,onChange:u=>J("enabled",u.currentTarget.checked)}),e("span",{children:"Enable this VNF"})]}),e("span",{class:"field-hint",children:ue})]}),m?e("p",{class:"form-error",role:"alert",children:m}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:L,onClick:se,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:L,children:L?"Saving\u2026":b?"Save VNF":"Add VNF"})]})]})})}function rr(t){return t.replace(/^SESSION_STATE_/,"").toLowerCase()}function Xa({vnf:t,onClose:n}){let[s,c]=l(null),[a,o]=l(""),[f,r]=l(!1),g=ee(null),w=async()=>{r(!0);try{let h=await M(`/api/vnfs/${t.id}/bgp`);c(h),o("")}catch(h){o(h instanceof Error?h.message:String(h))}finally{r(!1)}};K(()=>{g.current?.showModal(),w()},[]);let d=s?.session_state||"",k=s?s.established?"established":s.running?rr(d)||"starting":"not running":"loading";return e("dialog",{ref:g,class:"vnf-bgp-dialog","aria-labelledby":"vnf-bgp-title",onClose:n,onClick:h=>{h.target===h.currentTarget&&g.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"BGP monitor"}),e("h2",{id:"vnf-bgp-title",children:["BGP status \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>g.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 local AS ",t.config.local_as||"\u2014"," \xB7 peer AS"," ",t.config.peer_as||"\u2014"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,s?e(ye,{children:[e("div",{class:"bgp-summary",children:[e("span",{class:"status-badge","data-status":s.established?"running":s.running?"degraded":"disabled",children:k}),e("span",{class:"bgp-peer",children:["peer ",s.peer_address||t.config.peer_ip||"\u2014",s.peer_as?` \xB7 AS${s.peer_as}`:""]}),e("span",{class:"bgp-local",children:["local AS",s.local_as||"\u2014"]})]}),e("h3",{class:"bgp-section-title",children:"Advertised prefixes"}),e("div",{class:"table-wrap",children:s.advertised&&s.advertised.length>0?e("table",{children:[e("thead",{children:e("tr",{children:e("th",{scope:"col",children:"Prefix"})})}),e("tbody",{children:s.advertised.map(h=>e("tr",{children:e("td",{class:"mono",children:h})},h))})]}):e("div",{class:"empty",children:e("p",{children:"No advertised prefixes."})})}),e("h3",{class:"bgp-section-title",children:"Received prefixes"}),e("div",{class:"table-wrap",children:s.received&&s.received.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Prefix"}),e("th",{scope:"col",children:"Next hop"}),e("th",{scope:"col",children:"AS path"})]})}),e("tbody",{children:s.received.map(h=>e("tr",{children:[e("td",{class:"mono",children:h.prefix}),e("td",{class:"mono",children:h.next_hop||"\u2014"}),e("td",{class:"mono",children:h.as_path&&h.as_path.length>0?h.as_path.join(" "):"\u2014"})]},h.prefix))})]}):e("div",{class:"empty",children:e("p",{children:"No received prefixes."})})})]}):e("p",{class:"dialog-lede",children:f?"Loading\u2026":"BGP runtime not running."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>g.current?.close(),children:"Close"})]})]})}function lr(t,n){return t==="static"?"static":t==="dhcp"?n?`DHCP #${n}`:"DHCP":t||"\u2014"}function Za({vnf:t,onClose:n}){let[s,c]=l(null),[a,o]=l(""),[f,r]=l(!1),g=ee(null),w=async()=>{r(!0);try{let k=await M(`/api/vnfs/${t.id}/dns-records`);c(k),o("")}catch(k){o(k instanceof Error?k.message:String(k))}finally{r(!1)}};K(()=>{g.current?.showModal(),w()},[]);let d=s?.records||[];return e("dialog",{ref:g,class:"vnf-bgp-dialog","aria-labelledby":"vnf-dns-records-title",onClose:n,onClick:k=>{k.target===k.currentTarget&&g.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"DNS records"}),e("h2",{id:"vnf-dns-records-title",children:["DNS records \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>g.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 ",d.length," record",d.length===1?"":"s"," \xB7 static and DHCP-derived"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,s?.dynamic_error?e("div",{class:"banner",role:"alert",children:["Dynamic records unavailable: ",s.dynamic_error]}):null,e("div",{class:"table-wrap",children:d.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Value"}),e("th",{scope:"col",children:"TTL"}),e("th",{scope:"col",children:"Source"})]})}),e("tbody",{children:d.map((k,h)=>e("tr",{children:[e("td",{class:"mono",children:k.name}),e("td",{class:"mono",children:k.type}),e("td",{class:"mono",children:k.value}),e("td",{class:"mono",children:k.ttl||"\u2014"}),e("td",{children:lr(k.source,k.source_vnf_id)})]},`${k.name}-${k.type}-${k.value}-${h}`))})]}):e("div",{class:"empty",children:e("p",{children:f?"Loading records\u2026":"No DNS records."})})}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>g.current?.close(),children:"Close"})]})]})}function Ya({vnf:t,onClose:n}){let[s,c]=l([]),[a,o]=l(""),[f,r]=l(!1),g=ee(null),w=async()=>{r(!0);try{let d=await M(`/api/vnfs/${t.id}/leases`);c(Array.isArray(d.leases)?d.leases:[]),o("")}catch(d){o(d instanceof Error?d.message:String(d))}finally{r(!1)}};return K(()=>{g.current?.showModal(),w()},[]),e("dialog",{ref:g,class:"vnf-leases-dialog","aria-labelledby":"vnf-leases-title",onClose:n,onClick:d=>{d.target===d.currentTarget&&g.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Lease monitor"}),e("h2",{id:"vnf-leases-title",children:["Leases \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>g.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.type==="dhcpv6"?"DHCPv6":"DHCPv4"," \xB7 ",t.network_name||"network"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,e("div",{class:"table-wrap",children:s.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Client ID"}),e("th",{scope:"col",children:"Address"}),e("th",{scope:"col",children:"Hostname"}),e("th",{scope:"col",children:"Expires"}),e("th",{scope:"col",children:"Active"})]})}),e("tbody",{children:s.map(d=>e("tr",{children:[e("td",{class:"mono",children:d.client_id||"\u2014"}),e("td",{class:"mono",children:d.address||"\u2014"}),e("td",{children:d.hostname||"\u2014"}),e("td",{children:Wt(d.expires_at)}),e("td",{children:e("span",{class:"status-badge","data-status":d.active?"running":"disabled",children:d.active?"active":"expired"})})]},`${d.client_id}-${d.address}`))})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25CC"}),e("h3",{children:"No leases yet"}),e("p",{children:"Clients will appear here after they receive an address from this VNF."})]})}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>g.current?.close(),children:"Close"})]})]})}function Qa({vnf:t,onClose:n}){let[s,c]=l([]),[a,o]=l(""),[f,r]=l(!1),g=ee(null),w=async()=>{r(!0);try{let d=await M(`/api/vnfs/${t.id}/sessions`);c(Array.isArray(d.sessions)?d.sessions:[]),o("")}catch(d){o(d instanceof Error?d.message:String(d))}finally{r(!1)}};return K(()=>{g.current?.showModal(),w()},[]),e("dialog",{ref:g,class:"vnf-sessions-dialog","aria-labelledby":"vnf-sessions-title",onClose:n,onClick:d=>{d.target===d.currentTarget&&g.current?.close()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"NAT monitor"}),e("h2",{id:"vnf-sessions-title",children:["NAT44 sessions \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:()=>g.current?.close(),children:"\xD7"})]}),e("p",{class:"dialog-lede",children:[t.network_name||"network"," \xB7 outside ",t.config.outside_interface||"\u2014"]}),a?e("div",{class:"banner",role:"alert",children:a}):null,e("pre",{class:"nat-sessions",children:s.length>0?s.join(` +`):f?"Loading\u2026":"No active NAT44 sessions."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:f,onClick:()=>{w()},children:f?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:()=>g.current?.close(),children:"Close"})]})]})}function eo(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l(!1),[f,r]=l(!0),[g,w]=l(""),[d,k]=l(null),[h,L]=l(null),[T,m]=l(null),[y,C]=l(null),[E,P]=l(null),A=Me("vnf"),b=je();K(()=>{if(b.segments[0]!=="vnfs"){k(null),L(null),m(null),C(null),P(null);return}let p=b.segments[1];if(p==="new"){let N=Number(b.query.get("network"))||void 0;L(null),m(null),C(null),P(null),k({vnf:null,networkID:N});return}if(p&&p!=="new"){let N=t.find(ae=>String(ae.id)===p),X=b.segments[2];N&&(k(X==="edit"?{vnf:N}:null),L(X==="leases"?N:null),m(X==="sessions"?N:null),C(X==="bgp"?N:null),P(X==="dns"?N:null));return}k(null),L(null),m(null),C(null),P(null)},[b.path,b.search,t]);let _=p=>{n(p),window.dispatchEvent(new CustomEvent("rookery:vnfs-changed",{detail:p}))},R=async(p=!0)=>{try{let N=await M("/api/vnfs");_(Array.isArray(N)?N:[]),p&&w("")}catch(N){p&&w(N instanceof Error?N.message:String(N))}},H=async()=>{try{let p=await M("/api/networks");c(Array.isArray(p)?p:[])}catch{c([])}},G=async()=>{try{let p=await M("/api/status");o(!!p.vpp?.ready)}catch{o(!1)}},re=async()=>{r(!0),await Promise.allSettled([R(),H(),G()]),r(!1)};K(()=>{re();let p=v=>{let V=v.detail;Array.isArray(V)?c(V):H(),R(!1)},N=v=>{o(!!v.detail)},X=v=>{let V=v.detail;Array.isArray(V)&&n(V)},ae=v=>{let V=v.detail?.networkId;te(V?`/vnfs/new?network=${V}`:"/vnfs/new")},$=()=>{H()},S=v=>{v.detail!=="vnfs"&&(k(null),L(null),m(null),P(null))};return window.addEventListener("rookery:networks-changed",p),window.addEventListener("rookery:vpp-ready",N),window.addEventListener("rookery:vnfs-data",X),window.addEventListener("rookery:add-vnf",ae),window.addEventListener("rookery:refresh-networks",$),window.addEventListener("rookery:tab-changed",S),()=>{window.removeEventListener("rookery:networks-changed",p),window.removeEventListener("rookery:vpp-ready",N),window.removeEventListener("rookery:vnfs-data",X),window.removeEventListener("rookery:add-vnf",ae),window.removeEventListener("rookery:refresh-networks",$),window.removeEventListener("rookery:tab-changed",S)}},[]),K(()=>{let p=document.getElementById("vnf-tab-count");p&&(p.textContent=String(t.length))},[t.length]);let x=()=>{window.dispatchEvent(new CustomEvent("rookery:refresh-networks"))},F=p=>{k(null),te("/vnfs",!0),w(""),le(`${d?.vnf?"Saved":"Added"} VNF ${p.name}`),R(!1),(p.type==="nat44"||p.type==="dns")&&x()},B=async p=>{try{await M(`/api/vnfs/${p.id}/restart`,{method:"POST"}),w(""),le(`Started VNF ${p.name}`),R(!1)}catch(N){let X=N instanceof Error?N.message:String(N);w(`Could not start ${p.name}: ${X}`),N instanceof Ye&&N.status===503&&G()}},z=async p=>{if(window.confirm(p.type==="nat44"?`Delete VNF "${p.name}"? -This stops NAT44 and removes its VPP rules. The network BVI is kept until the network is deleted.`:E.type==="dns"?`Delete VNF "${E.name}"? +This stops NAT44 and removes its VPP rules. The network BVI is kept until the network is deleted.`:p.type==="dns"?`Delete VNF "${p.name}"? -This stops the DNS server and removes its local records.`:`Delete VNF "${E.name}"? +This stops the DNS server and removes its local records.`:`Delete VNF "${p.name}"? -This stops the function, removes its tap, and deletes its leases.`))try{await V(`/api/vnfs/${E.id}`,{method:"DELETE"});let se=t.filter(ce=>ce.id!==E.id);T(se),w(""),oe(`Deleted VNF ${E.name}`),E.type==="nat44"&&x()}catch(se){w(`Could not delete ${E.name}: ${se instanceof Error?se.message:String(se)}`)}};if(u)return e(Ga,{vnf:u.vnf,networks:s,vnfs:t,vppReady:a,initialNetworkID:u.networkID,onClose:()=>{k(null),Q("/vnfs",!0)},onSaved:R},u.vnf?`edit-${u.vnf.id}`:`new-${u.networkID??"default"}`);let Y=t.filter(E=>E.enabled).length,y=t.reduce((E,M)=>E+(M.lease_count||0),0),ae=t.length===0?f?"Loading VNFs\u2026":"No network functions registered":`${t.length} VNF${t.length===1?"":"s"} \xB7 ${Y} enabled \xB7 ${y} active leases`;return e(ge,{children:[e("section",{class:"panel","aria-labelledby":"vnfs-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"vnfs-heading",children:"Network functions"}),e("p",{class:"panel-subtitle",children:ae})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:()=>{ne()},children:"Refresh"}),e("button",{class:"button primary",type:"button",disabled:s.length===0,title:s.length===0?"Create a Network first":"",onClick:()=>Q("/vnfs/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add VNF"]})]})]}),b?e("div",{class:"banner",role:"alert",children:b}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"Active leases"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(E=>{let M=s.find($=>$.id===E.network_id),se=E.type==="nat44"?"NAT44":E.type==="bgp"?"BGP":E.type==="dns"?"DNS":E.type==="dhcpv6"?"DHCPv6":"DHCPv4",ce=E.type==="dhcpv4"||E.type==="dhcpv6",v=Array.isArray(E.config.dnat_rules)?E.config.dnat_rules.length:0;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:E.name}),E.type==="nat44"&&E.config.outside_interface?e("span",{class:"vm-description",children:["outside ",E.config.outside_interface,v?` \xB7 ${v} DNAT`:""]}):E.type==="dns"?e("span",{class:"vm-description",children:[Array.isArray(E.config.dns_records)?E.config.dns_records.length:0," static",E.config.dns_forward?" \xB7 forwarding":" \xB7 authoritative"]}):E.tap_host_name?e("span",{class:"vm-description",children:[E.tap_host_name,E.type==="dhcpv4"&&Array.isArray(E.config.static_assignments)&&E.config.static_assignments.length>0?` \xB7 ${E.config.static_assignments.length} static`:""]}):null,e(De,{entityType:"vnf",entityID:E.id,name:E.name,labels:N.labels[String(E.id)],onSaved:$=>N.setEntityLabels(String(E.id),$)})]}),e("td",{class:"mono",children:se}),e("td",{class:"mono",children:M?.name||E.network_name||`network ${E.network_id}`}),e("td",{children:e("span",{class:"status-badge","data-status":E.status||"disabled",title:E.status_detail,children:E.status||"disabled"})}),e("td",{class:ce&&E.lease_count?"mono":"mono muted",children:ce?String(E.lease_count||0):"\u2014"}),e("td",{class:"updated hide-sm",children:Wt(E.updated_at)}),e("td",{children:e("div",{class:"actions",children:[E.type==="bgp"?e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vnfs/${E.id}/bgp`),children:"Status"}):E.type==="dns"?e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vnfs/${E.id}/dns`),children:"Records"}):e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vnfs/${E.id}/${E.type==="nat44"?"sessions":"leases"}`),children:E.type==="nat44"?"Sessions":"Leases"}),e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vnfs/${E.id}/edit`),children:"Edit"}),e("button",{class:"row-action",type:"button",disabled:!a,title:a?"":"VPP must be ready to start a VNF.",onClick:()=>{F(E)},children:E.enabled?"Restart":"Start"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{W(E)},children:"Delete"})]})})]},E.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25C8"}),e("h3",{children:"No network functions yet"}),e("p",{children:"Add a DHCP, DNS, NAT44, or BGP function and attach it to a Network."}),e("button",{class:"button primary",type:"button",disabled:s.length===0,onClick:()=>Q("/vnfs/new"),children:"Add a VNF"})]})})]}),h?e(Xa,{vnf:h,onClose:()=>{A(null),Q("/vnfs",!0)}},`leases-${h.id}`):null,I?e(Za,{vnf:I,onClose:()=>{m(null),Q("/vnfs",!0)}},`sessions-${I.id}`):null,g?e(Ka,{vnf:g,onClose:()=>{P(null),Q("/vnfs",!0)}},`bgp-${g.id}`):null,L?e(Ja,{vnf:L,onClose:()=>{S(null),Q("/vnfs",!0)}},`dns-${L.id}`):null]})}function Ya(t){t&&Fe(e(ja,{}),t)}function Ge(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function zt(t){if(t>=1024){let n=t/1024;return`${Number.isInteger(n)?n:n.toFixed(1)} GB`}return`${t} MB`}function Qa(t){let n=Math.max(0,Math.round(Number(t)||0)),s=Math.floor(n/86400),l=Math.floor(n%86400/3600),a=Math.floor(n%3600/60),o=n%60;return s>0?`${s}d ${l}h`:l>0?`${l}h ${a}m`:a>0?`${a}m ${o}s`:`${o}s`}function ir(t){if(!t||typeof t.getContext!="function")return null;let n=t.getContext("2d");if(!n)return null;let s=window.devicePixelRatio||1,l=Math.max(280,Math.round(t.getBoundingClientRect().width||620)),a=150,o=Math.round(l*s),f=Math.round(a*s);return(t.width!==o||t.height!==f)&&(t.width=o,t.height=f),n.setTransform(s,0,0,s,0,0),{context:n,width:l,height:a}}function wt(t,n,s={}){let l=ir(t);if(!l)return;let{context:a,width:o,height:f}=l;a.clearRect(0,0,o,f);let r={left:52,right:10,top:12,bottom:18},b=Math.max(1,o-r.left-r.right),w=Math.max(1,f-r.top-r.bottom),u=s.format||(m=>String(Math.round(m))),k=n.flatMap(m=>m.values||[]).filter(m=>Number.isFinite(m)),h=s.max||0;h||(h=k.length?Math.max(...k)*1.15:0),h<=0&&(h=1),a.strokeStyle="rgba(148, 163, 184, 0.14)",a.fillStyle="#94a3b8",a.font="10px SFMono-Regular, Consolas, monospace",a.lineWidth=1;for(let m=0;m<=2;m+=1){let g=r.top+w*m/2;a.beginPath(),a.moveTo(r.left,g),a.lineTo(r.left+b,g),a.stroke();let P=u(h*(1-m/2));a.fillText(P,4,g+3)}let A=Number(s.marker);if(Number.isFinite(A)&&A>0&&A<=h){let m=r.top+w-A/h*w;a.save(),a.setLineDash([4,4]),a.strokeStyle="rgba(226, 232, 240, 0.35)",a.beginPath(),a.moveTo(r.left,m),a.lineTo(r.left+b,m),a.stroke(),a.restore()}let I=!1;for(let m of n){let g=m.values||[];if(g.length===0)continue;a.strokeStyle=m.color,a.fillStyle=m.color,a.lineWidth=2,a.lineJoin="round",a.lineCap="round",a.beginPath();let P=!1;for(let L=0;L0?t.disks:t.disk?[t.disk]:t.disk_id!==void 0?[]:[]:[]}function dr(t){return Array.isArray(t?.interfaces)?t.interfaces:[]}function eo({vm:t,disks:n,media:s,interfaces:l,thinPoolReady:a,vppReady:o,onClose:f,onSaved:r}){let b=t!==null,[w,u]=d(t?.name??""),[k,h]=d(t?.description??""),A=t?.status??"stopped",[I,m]=d(String(t?.vcpus??1)),[g,P]=d(String(t?.memory_mb??512)),[L,S]=d(!!t?.console_enabled),[N,p]=d(null),[T,H]=d(t?.cgroup_class||""),[G,J]=ke("vm",t?.id),[ne,x]=d(t?.boot_media_id!==void 0?String(t.boot_media_id):""),[R,F]=d(()=>lr(t)),[W,Y]=d(()=>dr(t)),[y,ae]=d(""),[E,M]=d(""),[se,ce]=d(""),[v,$]=d(""),[C,U]=d(!1),[de,ie]=d(""),pe=te(null),ee=te(null),ue=te(null),me=!!(t&&(t.status==="running"||t.status==="starting")),c=new Set(R.map(O=>O.id)),_=new Set(W.map(O=>O.id)),B=Array.isArray(N?.classes)?N.classes:[],X=B.filter(O=>!B.some(_e=>_e.name.startsWith(O.name+"/"))),Z=n.filter(O=>O.is_template||c.has(O.id)?!1:!O.attached_vm_id||t!==null&&O.attached_vm_id===t.id),fe=l.filter(O=>_.has(O.id)?!1:!O.attached_vm_id&&!O.attached_lightvm_id||t!==null&&O.attached_vm_id===t.id);z(()=>{pe.current?.showModal(),ee.current?.focus()},[]),z(()=>{V("/api/cgroup-settings").then(p).catch(()=>p(null))},[]),z(()=>{me?ce("Stop the VM before changing its disks."):Z.length===0&&R.length===0?ce("Create a disk on the Disks tab first."):ce("Choose a disk and click Add. The first disk is the boot disk. Remove disks with the buttons below.")},[Z.length,R.length,me]),z(()=>{me?$("Stop the VM before changing network interfaces."):fe.length===0&&W.length===0?$("Create a Rookery Virtual Interface on the Network tab first."):$("Choose an interface and click Add. Remove interfaces with the buttons below.")},[fe.length,W.length,me]);let Ie=()=>{C||pe.current?.close()},le=()=>{let O=Number(y);if(!O||me)return;let Ee=n.find(q=>q.id===O)??R.find(q=>q.id===O);Ee&&(F(q=>q.some(we=>we.id===Ee.id)?q:[...q,Ee]),ae(""))},be=O=>{me||F(_e=>_e.filter(Ee=>Ee.id!==O))},ve=()=>{let O=Number(E);if(!O||me)return;let _e=l.find(Ee=>Ee.id===O)||W.find(Ee=>Ee.id===O);_e&&(Y(Ee=>Ee.some(q=>q.id===_e.id)?Ee:[...Ee,_e]),M(""))},K=O=>{me||Y(_e=>_e.filter(Ee=>Ee.id!==O))},$e=async O=>{if(O.preventDefault(),C||!ue.current?.reportValidity())return;if(R.length===0){ie("Select at least one disk.");return}if(!b){let q=R.some(we=>we.source!=="host-device");if(!o){ie("FD.io VPP is not ready. VM creation is blocked until VPP is reachable.");return}if(q&&!a){ie("The thin pool is not ready. Select a host passthrough disk or fix the thin pool first.");return}}let _e={name:w.trim(),description:k.trim(),vcpus:Number(I),memory_mb:Number(g),console_enabled:L};b?me||(_e.disk_ids=R.map(q=>q.id),_e.interface_ids=W.map(q=>q.id),_e.replace_disk_ids=!0,_e.replace_interface_ids=!0):(_e.disk_ids=R.map(q=>q.id),W.length>0&&(_e.interface_ids=W.map(q=>q.id)));let Ee=Number(ne);Ee&&(_e.boot_media_id=Ee),_e.cgroup_class=T,ie(""),U(!0);try{let q=await V(b?`/api/vms/${t?.id}`:"/api/vms",{method:b?"PUT":"POST",body:JSON.stringify(_e)});await Ce("vm",q.id,q.name||w.trim(),G),r(q)}catch(q){ie(q instanceof Error?q.message:String(q))}finally{U(!1)}};return e("dialog",{ref:pe,id:"vm-dialog","aria-labelledby":"dialog-title",onClose:f,onClick:O=>{O.target===O.currentTarget&&Ie()},onCancel:O=>{C&&O.preventDefault()},children:e("form",{ref:ue,novalidate:!0,onSubmit:O=>{$e(O)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:b?"Edit instance":"New instance"}),e("h2",{id:"dialog-title",children:b?`Edit ${t?.name}`:"Create VM"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:Ie,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:ee,type:"text",required:!0,maxlength:64,pattern:rr,autocomplete:"off",spellcheck:!1,placeholder:"web-01",value:w,onInput:O=>u(O.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Power"}),e("input",{type:"text",value:b?A:"Starts automatically",disabled:!0}),e("span",{class:"field-hint",children:b?"Managed by Start/Stop on the inventory table.":"New VMs start right away; use Stop on the inventory table to shut one down."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"vCPUs"}),e("input",{type:"number",required:!0,min:1,max:128,step:1,value:I,onInput:O=>m(O.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory (MB)"}),e("input",{type:"number",required:!0,min:128,max:1048576,step:128,value:g,onInput:O=>P(O.currentTarget.value)})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Disks"}),e("div",{class:"interface-picker",children:[e("select",{value:y,disabled:me||Z.length===0,onChange:O=>ae(O.currentTarget.value),children:[e("option",{value:"",children:Z.length>0?"Select a disk\u2026":"No disks available"}),Z.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",Nn(O.size_bytes||O.size_gb*1024**3),O.source==="host-device"?" \xB7 passthrough":""]},O.id))]}),e("button",{class:"button ghost",type:"button",disabled:me||Z.length===0,onClick:le,children:"Add"})]}),e("ul",{class:"interface-list",children:R.length===0?e("li",{class:"empty",children:"No disks selected"}):R.map((O,_e)=>e("li",{children:[e("span",{class:"interface-list-name",children:[O.name,e("span",{class:"interface-list-network",children:[Nn(O.size_bytes||O.size_gb*1024**3),O.source==="host-device"?" \xB7 passthrough":"",_e===0?" \xB7 boot":""]})]}),e("button",{class:"row-action danger",type:"button",disabled:me,onClick:()=>be(O.id),children:"Remove"})]},O.id))}),e("span",{class:"field-hint",children:se})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Boot media ",e("em",{children:"optional"})]}),e("select",{value:ne,onChange:O=>x(O.currentTarget.value),children:[e("option",{value:"",children:"Boot from disk"}),s.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",Nn(O.size_bytes)]},O.id))]}),e("span",{class:"field-hint",children:"Read-only ISO from the Installation media tab. You can change this later."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"field-label",children:"Console access"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:L,onChange:O=>S(O.currentTarget.checked)}),e("span",{children:"Allow end users to open this VM's console"})]}),e("span",{class:"field-hint",children:"When disabled, the console page and VNC WebSocket are blocked."})]}),e("div",{class:"field field-wide cgroup-vm-section",children:e("label",{class:"field",children:[e("span",{class:"field-label",children:["Cgroup class ",e("em",{children:"optional"})]}),e("select",{value:T,onChange:O=>H(O.currentTarget.value),children:[e("option",{value:"",children:"Shared cgroup (host defaults)"}),!X.some(O=>O.name===T)&&T?e("option",{value:T,children:[T," (not a leaf class)"]}):null,X.map(O=>e("option",{value:O.name,children:O.name},O.name))]}),e("span",{class:"field-hint",children:["Select a class defined in Settings. Changes are applied immediately to a running VM by moving its QEMU process into the class cgroup.",N&&!N.enabled?" Host-wide cgroup limits are disabled in Settings; the class applies when host cgroup limits are enabled.":""]})]})}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Network interfaces ",e("em",{children:"optional"})]}),e("div",{class:"interface-picker",children:[e("select",{value:E,disabled:me||fe.length===0,onChange:O=>M(O.currentTarget.value),children:[e("option",{value:"",children:fe.length>0?"Select an interface\u2026":"No interfaces available"}),fe.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",O.network_name]},O.id))]}),e("button",{class:"button ghost",type:"button",disabled:me||fe.length===0,onClick:ve,children:"Add"})]}),e("ul",{class:"interface-list",children:W.length===0?e("li",{class:"empty",children:"No interfaces selected"}):W.map(O=>e("li",{children:[e("span",{class:"interface-list-name",children:[O.name,e("span",{class:"interface-list-network",children:O.network_name})]}),e("button",{class:"row-action danger",type:"button",disabled:me,onClick:()=>K(O.id),children:"Remove"})]},O.id))}),e("span",{class:"field-hint",children:v})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:3,maxlength:2e3,placeholder:"What is this instance for?",value:k,onInput:O=>h(O.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Se,{labels:G,onChange:J,disabled:C})})]}),de?e("p",{class:"form-error",role:"alert",children:de}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:C,onClick:Ie,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:C,children:C?b?"Saving\u2026":"Creating\u2026":b?"Save changes":"Create VM"})]})]})})}function Nn(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function to({vm:t,onClose:n}){let[s,l]=d(null),[a,o]=d([]),[f,r]=d(""),[b,w]=d(!1),u=te(!1),k=te(null),h=te(null),A=te(null),I=te(null),m=te(null),g=te(null),P=async()=>{if(!u.current){u.current=!0,w(!0);try{let p=await V(`/api/vms/${encodeURIComponent(t.id)}/metrics`),T=Date.now(),H=Number(p.cpu_seconds_total)||0,G=Number(p.disk_read_bytes_total)||0,J=Number(p.disk_write_bytes_total)||0,ne=Number(p.network_rx_bytes_total)||0,x=Number(p.network_tx_bytes_total)||0,R=h.current,F=null,W=null,Y=null,y=null,ae=null;if(R&&T>R.at){let E=(T-R.at)/1e3;H>=R.cpu&&(F=(H-R.cpu)/E*100),G>=R.read&&(W=(G-R.read)/E),J>=R.write&&(Y=(J-R.write)/E),ne>=R.netRead&&(y=(ne-R.netRead)/E),x>=R.netWrite&&(ae=(x-R.netWrite)/E)}p.running?(h.current={at:T,cpu:H,read:G,write:J,netRead:ne,netWrite:x},o(E=>{let M=[...E,{cpu:F,rss:Number(p.memory_rss_bytes)||0,read:W,write:Y,netRead:y,netWrite:ae}];return M.length>60?M.slice(M.length-60):M})):(h.current=null,o([])),l(p),r("")}catch(p){r(`Could not read VM metrics: ${p instanceof Error?p.message:String(p)}`)}finally{u.current=!1,w(!1)}}};z(()=>{k.current?.showModal(),P();let p=window.setInterval(()=>{document.hidden||P()},2e3);return()=>{window.clearInterval(p)}},[]),z(()=>{let p=a.map(y=>y.cpu),T=a.map(y=>y.rss),H=a.map(y=>y.read),G=a.map(y=>y.write),J=a.map(y=>y.netRead),ne=a.map(y=>y.netWrite),x=p.filter(y=>Number.isFinite(y)),R=T.filter(y=>Number.isFinite(y)),F=[...H,...G].filter(y=>Number.isFinite(y)),W=[...J,...ne].filter(y=>Number.isFinite(y)),Y=Math.max(0,Number(t.memory_mb||0)*1024*1024);wt(A.current,[{values:p,color:"#60a5fa"}],{max:Math.max(100,x.length?Math.max(...x)*1.15:0),format:y=>`${Math.round(y)}%`}),wt(I.current,[{values:T,color:"#34d399"}],{max:Math.max(Y,R.length?Math.max(...R)*1.15:0),marker:Y,format:Ge}),wt(m.current,[{values:H,color:"#f59e0b"},{values:G,color:"#f472b6"}],{max:F.length?Math.max(...F)*1.15:0,format:y=>`${Ge(y)}/s`}),wt(g.current,[{values:J,color:"#38bdf8"},{values:ne,color:"#a78bfa"}],{max:W.length?Math.max(...W)*1.15:0,format:y=>`${Ge(y)}/s`})},[a,t.memory_mb]);let L=()=>{k.current?.close()},S=s,N=!!S?.running;return e("dialog",{ref:k,id:"vm-monitor-dialog","aria-labelledby":"vm-monitor-title",onClose:n,onClick:p=>{p.target===p.currentTarget&&L()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Live telemetry"}),e("h2",{id:"vm-monitor-title",children:["VM monitor \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close monitor",onClick:L,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:"QEMU process metrics"}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"monitor-grid",children:[e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Process"}),e("strong",{class:"metric-value","data-status":N?"running":"stopped",children:N?"running":"stopped"}),e("span",{class:"metric-detail",children:["PID ",S?.pid||"\u2014"]})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"CPU"}),e("strong",{class:"metric-value",children:N?a.length<=1?"sampling\u2026":`${(a[a.length-1]?.cpu??0).toFixed(1)}%`:"\u2014"}),e("span",{class:"metric-detail",children:S?`${S.vcpus} vCPU`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Memory"}),e("strong",{class:"metric-value",children:N?Ge(Number(S?.memory_rss_bytes)||0):"\u2014"}),e("span",{class:"metric-detail",children:S?`${zt(S.memory_mb)} configured${N?` \xB7 ${Ge(Number(S.memory_virtual_bytes)||0)} virtual`:""}`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Disk read"}),e("strong",{class:"metric-value",children:N?a.length<=1?"sampling\u2026":`${Ge(a[a.length-1]?.read??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:S?`${Ge(Number(S.disk_read_bytes_total)||0)} total`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Disk write"}),e("strong",{class:"metric-value",children:N?a.length<=1?"sampling\u2026":`${Ge(a[a.length-1]?.write??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:S?`${Ge(Number(S.disk_write_bytes_total)||0)} total`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Network I/O"}),e("strong",{class:"metric-value",children:N?a.length<=1?"sampling\u2026":`\u2193 ${Ge(a[a.length-1]?.netRead??0)}/s \xB7 \u2191 ${Ge(a[a.length-1]?.netWrite??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:S?`RX ${Ge(Number(S.network_rx_bytes_total)||0)} \xB7 TX ${Ge(Number(S.network_tx_bytes_total)||0)}`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Uptime"}),e("strong",{class:"metric-value",children:N&&S?Qa(Number(S.uptime_seconds)||0):"\u2014"}),e("span",{class:"metric-detail",children:"since QEMU started"})]})]}),e("div",{class:"monitor-charts",children:[e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"CPU history"}),e("span",{class:"chart-note",children:"last 60 samples"})]}),e("canvas",{ref:A,class:"metric-chart",role:"img","aria-label":"CPU usage history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Memory RSS"}),e("span",{class:"chart-note",children:"last 60 samples"})]}),e("canvas",{ref:I,class:"metric-chart",role:"img","aria-label":"Memory RSS history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Disk I/O"}),e("span",{class:"chart-legend",children:[e("i",{class:"legend-read"}),"read ",e("i",{class:"legend-write"}),"write"]})]}),e("canvas",{ref:m,class:"metric-chart",role:"img","aria-label":"Disk read and write history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Network I/O"}),e("span",{class:"chart-legend",children:[e("i",{class:"legend-net-rx"}),"RX ",e("i",{class:"legend-net-tx"}),"TX"]})]}),e("canvas",{ref:g,class:"metric-chart",role:"img","aria-label":"Network receive and transmit history"})]})]}),e("p",{class:"field-hint",children:S&&N?`Sampled ${new Date(S.sampled_at).toLocaleTimeString()}`:"VM is not running."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:b,onClick:()=>{P()},children:b?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:L,children:"Close"})]})]})}var no=new Intl.NumberFormat;function cr(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function ur(t){return Array.isArray(t.disks)&&t.disks.length>0?t.disks:t.disk?[t.disk]:[]}function so(){let[t,n]=d([]),[s,l]=d([]),[a,o]=d([]),[f,r]=d([]),[b,w]=d(!1),[u,k]=d(!1),[h,A]=d(!0),[I,m]=d(""),[g,P]=d(null),[L,S]=d(null),N=Ne("vm"),p=ze();z(()=>{if(p.segments[0]!=="vms"){P(null),S(null);return}let v=p.segments[1],$=p.segments[2];if(v==="new"){S(null),P({vm:null});return}if(v&&v!=="new"){let C=t.find(U=>U.id===v);if(C&&$==="edit"){S(null),P({vm:C});return}if(C&&$==="monitor"){P(null),S(C);return}}P(null),S(null)},[p.path,t]);let T=v=>{n(v),window.dispatchEvent(new CustomEvent("rookery:vms-changed",{detail:v}))},H=async(v=!0)=>{try{let $=await V("/api/vms");T(Array.isArray($)?$:[]),v&&m("")}catch($){v&&m($ instanceof Error?$.message:String($))}},G=async()=>{try{let v=await V("/api/disks");l(Array.isArray(v)?v:[])}catch{l([])}},J=async()=>{try{let v=await V("/api/media");o(Array.isArray(v)?v:[])}catch{o([])}},ne=async()=>{try{let v=await V("/api/interfaces");r(Array.isArray(v)?v:[])}catch{r([])}},x=async()=>{try{let v=await V("/api/status");w(!!v.thin_pool?.ready),k(!!v.vpp?.ready)}catch{w(!1),k(!1)}},R=async()=>{A(!0),await Promise.allSettled([H(),G(),J(),ne(),x()]),A(!1)};z(()=>{R();let v=ee=>{let ue=ee.detail;Array.isArray(ue)&&l(ue)},$=ee=>{let ue=ee.detail;Array.isArray(ue)&&o(ue)},C=ee=>{let ue=ee.detail;Array.isArray(ue)&&r(ue)},U=ee=>{let ue=ee.detail;ue&&typeof ue.thinPool=="boolean"&&w(ue.thinPool)},de=ee=>{let ue=ee.detail;typeof ue=="boolean"&&k(ue)},ie=ee=>{ee.detail==="vms"&&R()},pe=()=>{Q("/vms/new")};return window.addEventListener("rookery:disks-changed",v),window.addEventListener("rookery:media-changed",$),window.addEventListener("rookery:interfaces-changed",C),window.addEventListener("rookery:readiness-changed",U),window.addEventListener("rookery:vpp-ready",de),window.addEventListener("rookery:tab-changed",ie),window.addEventListener("rookery:add-vm",pe),()=>{window.removeEventListener("rookery:disks-changed",v),window.removeEventListener("rookery:media-changed",$),window.removeEventListener("rookery:interfaces-changed",C),window.removeEventListener("rookery:readiness-changed",U),window.removeEventListener("rookery:vpp-ready",de),window.removeEventListener("rookery:tab-changed",ie),window.removeEventListener("rookery:add-vm",pe)}},[]),z(()=>{let v=`${t.length} VM${t.length===1?"":"s"}`,$=document.getElementById("vm-count");$&&($.textContent=v);let C=document.getElementById("vm-tab-count");C&&(C.textContent=String(t.length))},[t.length]);let F=s.filter(v=>!v.attached_vm_id&&!v.is_template),W=F.some(v=>v.source==="host-device");z(()=>{let v=document.getElementById("new-vm"),$=document.getElementById("new-vm-hint");if(!v||!$)return;let C="";u?F.length===0?C="Create an unattached disk on the Disks tab first.":!b&&!W&&(C="VM creation is blocked until the thin pool is ready or a host passthrough disk is available."):C="VM creation is blocked until FD.io VPP is ready.",v.disabled=C!=="",v.title=C,$.textContent=C,$.hidden=C===""},[F.length,W,b,u]);let Y=()=>{if(!u){oe("VM creation is blocked until FD.io VPP is ready.",!0);return}if(F.length===0){oe("Create an unattached disk on the Disks tab first.",!0);return}if(!b&&!W){oe("The thin pool is not ready and no host passthrough disk is available.",!0);return}P({vm:null}),Q("/vms/new")},y=async v=>{if(window.confirm(`Delete "${v.name}"? +This stops the function, removes its tap, and deletes its leases.`))try{await M(`/api/vnfs/${p.id}`,{method:"DELETE"});let X=t.filter(ae=>ae.id!==p.id);_(X),w(""),le(`Deleted VNF ${p.name}`),p.type==="nat44"&&x()}catch(X){w(`Could not delete ${p.name}: ${X instanceof Error?X.message:String(X)}`)}};if(d)return e(Ja,{vnf:d.vnf,networks:s,vnfs:t,vppReady:a,initialNetworkID:d.networkID,onClose:()=>{k(null),te("/vnfs",!0)},onSaved:F},d.vnf?`edit-${d.vnf.id}`:`new-${d.networkID??"default"}`);let J=t.filter(p=>p.enabled).length,se=t.reduce((p,N)=>p+(N.lease_count||0),0),ge=t.length===0?f?"Loading VNFs\u2026":"No network functions registered":`${t.length} VNF${t.length===1?"":"s"} \xB7 ${J} enabled \xB7 ${se} active leases`;return e(ye,{children:[e("section",{class:"panel","aria-labelledby":"vnfs-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"vnfs-heading",children:"Network functions"}),e("p",{class:"panel-subtitle",children:ge})]}),e("div",{class:"actions",children:[e("button",{class:"button ghost",type:"button",onClick:()=>{re()},children:"Refresh"}),e("button",{class:"button primary",type:"button",disabled:s.length===0,title:s.length===0?"Create a Network first":"",onClick:()=>te("/vnfs/new"),children:[e("span",{class:"button-plus","aria-hidden":"true",children:"+"}),"Add VNF"]})]})]}),g?e("div",{class:"banner",role:"alert",children:g}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Type"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"Active leases"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(p=>{let N=s.find(S=>S.id===p.network_id),X=p.type==="nat44"?"NAT44":p.type==="bgp"?"BGP":p.type==="dns"?"DNS":p.type==="dhcpv6"?"DHCPv6":"DHCPv4",ae=p.type==="dhcpv4"||p.type==="dhcpv6",$=Array.isArray(p.config.dnat_rules)?p.config.dnat_rules.length:0;return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:p.name}),p.type==="nat44"&&p.config.outside_interface?e("span",{class:"vm-description",children:["outside ",p.config.outside_interface,$?` \xB7 ${$} DNAT`:""]}):p.type==="dns"?e("span",{class:"vm-description",children:[Array.isArray(p.config.dns_records)?p.config.dns_records.length:0," static",p.config.dns_forward?" \xB7 forwarding":" \xB7 authoritative"]}):p.tap_host_name?e("span",{class:"vm-description",children:[p.tap_host_name,p.type==="dhcpv4"&&Array.isArray(p.config.static_assignments)&&p.config.static_assignments.length>0?` \xB7 ${p.config.static_assignments.length} static`:""]}):null,e(Ve,{entityType:"vnf",entityID:p.id,name:p.name,labels:A.labels[String(p.id)],onSaved:S=>A.setEntityLabels(String(p.id),S)})]}),e("td",{class:"mono",children:X}),e("td",{class:"mono",children:N?.name||p.network_name||`network ${p.network_id}`}),e("td",{children:e("span",{class:"status-badge","data-status":p.status||"disabled",title:p.status_detail,children:p.status||"disabled"})}),e("td",{class:ae&&p.lease_count?"mono":"mono muted",children:ae?String(p.lease_count||0):"\u2014"}),e("td",{class:"updated hide-sm",children:Wt(p.updated_at)}),e("td",{children:e("div",{class:"actions",children:[p.type==="bgp"?e("button",{class:"row-action",type:"button",onClick:()=>te(`/vnfs/${p.id}/bgp`),children:"Status"}):p.type==="dns"?e("button",{class:"row-action",type:"button",onClick:()=>te(`/vnfs/${p.id}/dns`),children:"Records"}):e("button",{class:"row-action",type:"button",onClick:()=>te(`/vnfs/${p.id}/${p.type==="nat44"?"sessions":"leases"}`),children:p.type==="nat44"?"Sessions":"Leases"}),e("button",{class:"row-action",type:"button",onClick:()=>te(`/vnfs/${p.id}/edit`),children:"Edit"}),e("button",{class:"row-action",type:"button",disabled:!a,title:a?"":"VPP must be ready to start a VNF.",onClick:()=>{B(p)},children:p.enabled?"Restart":"Start"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{z(p)},children:"Delete"})]})})]},p.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u25C8"}),e("h3",{children:"No network functions yet"}),e("p",{children:"Add a DHCP, DNS, NAT44, or BGP function and attach it to a Network."}),e("button",{class:"button primary",type:"button",disabled:s.length===0,onClick:()=>te("/vnfs/new"),children:"Add a VNF"})]})})]}),h?e(Ya,{vnf:h,onClose:()=>{L(null),te("/vnfs",!0)}},`leases-${h.id}`):null,T?e(Qa,{vnf:T,onClose:()=>{m(null),te("/vnfs",!0)}},`sessions-${T.id}`):null,y?e(Xa,{vnf:y,onClose:()=>{C(null),te("/vnfs",!0)}},`bgp-${y.id}`):null,E?e(Za,{vnf:E,onClose:()=>{P(null),te("/vnfs",!0)}},`dns-${E.id}`):null]})}function to(t){t&&Oe(e(eo,{}),t)}function Ue(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function Gt(t){if(t>=1024){let n=t/1024;return`${Number.isInteger(n)?n:n.toFixed(1)} GB`}return`${t} MB`}function no(t){let n=Math.max(0,Math.round(Number(t)||0)),s=Math.floor(n/86400),c=Math.floor(n%86400/3600),a=Math.floor(n%3600/60),o=n%60;return s>0?`${s}d ${c}h`:c>0?`${c}h ${a}m`:a>0?`${a}m ${o}s`:`${o}s`}function cr(t){if(!t||typeof t.getContext!="function")return null;let n=t.getContext("2d");if(!n)return null;let s=window.devicePixelRatio||1,c=Math.max(280,Math.round(t.getBoundingClientRect().width||620)),a=150,o=Math.round(c*s),f=Math.round(a*s);return(t.width!==o||t.height!==f)&&(t.width=o,t.height=f),n.setTransform(s,0,0,s,0,0),{context:n,width:c,height:a}}function wt(t,n,s={}){let c=cr(t);if(!c)return;let{context:a,width:o,height:f}=c;a.clearRect(0,0,o,f);let r={left:52,right:10,top:12,bottom:18},g=Math.max(1,o-r.left-r.right),w=Math.max(1,f-r.top-r.bottom),d=s.format||(m=>String(Math.round(m))),k=n.flatMap(m=>m.values||[]).filter(m=>Number.isFinite(m)),h=s.max||0;h||(h=k.length?Math.max(...k)*1.15:0),h<=0&&(h=1),a.strokeStyle="rgba(148, 163, 184, 0.14)",a.fillStyle="#94a3b8",a.font="10px SFMono-Regular, Consolas, monospace",a.lineWidth=1;for(let m=0;m<=2;m+=1){let y=r.top+w*m/2;a.beginPath(),a.moveTo(r.left,y),a.lineTo(r.left+g,y),a.stroke();let C=d(h*(1-m/2));a.fillText(C,4,y+3)}let L=Number(s.marker);if(Number.isFinite(L)&&L>0&&L<=h){let m=r.top+w-L/h*w;a.save(),a.setLineDash([4,4]),a.strokeStyle="rgba(226, 232, 240, 0.35)",a.beginPath(),a.moveTo(r.left,m),a.lineTo(r.left+g,m),a.stroke(),a.restore()}let T=!1;for(let m of n){let y=m.values||[];if(y.length===0)continue;a.strokeStyle=m.color,a.fillStyle=m.color,a.lineWidth=2,a.lineJoin="round",a.lineCap="round",a.beginPath();let C=!1;for(let E=0;E0?t.disks:t.disk?[t.disk]:t.disk_id!==void 0?[]:[]:[]}function pr(t){return Array.isArray(t?.interfaces)?t.interfaces:[]}function so({vm:t,disks:n,media:s,interfaces:c,thinPoolReady:a,vppReady:o,onClose:f,onSaved:r}){let g=t!==null,[w,d]=l(t?.name??""),[k,h]=l(t?.description??""),L=t?.status??"stopped",[T,m]=l(String(t?.vcpus??1)),[y,C]=l(String(t?.memory_mb??512)),[E,P]=l(!!t?.console_enabled),[A,b]=l(null),[_,R]=l(t?.cgroup_class||""),[H,G]=Pe("vm",t?.id),[re,x]=l(t?.boot_media_id!==void 0?String(t.boot_media_id):""),[F,B]=l(()=>ur(t)),[z,J]=l(()=>pr(t)),[se,ge]=l(""),[p,N]=l(""),[X,ae]=l(""),[$,S]=l(""),[v,V]=l(!1),[ie,pe]=l(""),fe=ee(null),ne=ee(null),me=ee(null),ue=!!(t&&(t.status==="running"||t.status==="starting")),u=new Set(F.map(O=>O.id)),I=new Set(z.map(O=>O.id)),U=Array.isArray(A?.classes)?A.classes:[],Q=U.filter(O=>!U.some(we=>we.name.startsWith(O.name+"/"))),Z=n.filter(O=>O.is_template||u.has(O.id)||O.deletion_state?!1:!O.attached_vm_id||t!==null&&O.attached_vm_id===t.id),q=c.filter(O=>I.has(O.id)?!1:!O.attached_vm_id&&!O.attached_lightvm_id||t!==null&&O.attached_vm_id===t.id);K(()=>{fe.current?.showModal(),ne.current?.focus()},[]),K(()=>{M("/api/cgroup-settings").then(b).catch(()=>b(null))},[]),K(()=>{ue?ae("Stop the VM before changing its disks."):Z.length===0&&F.length===0?ae("Create a disk on the Disks tab first."):ae("Choose a disk and click Add. The first disk is the boot disk. Remove disks with the buttons below.")},[Z.length,F.length,ue]),K(()=>{ue?S("Stop the VM before changing network interfaces."):q.length===0&&z.length===0?S("Create a Rookery Virtual Interface on the Network tab first."):S("Choose an interface and click Add. Remove interfaces with the buttons below.")},[q.length,z.length,ue]);let _e=()=>{v||fe.current?.close()},oe=()=>{let O=Number(se);if(!O||ue)return;let Te=n.find(W=>W.id===O)??F.find(W=>W.id===O);Te&&(B(W=>W.some(ke=>ke.id===Te.id)?W:[...W,Te]),ge(""))},he=O=>{ue||B(we=>we.filter(Te=>Te.id!==O))},ve=()=>{let O=Number(p);if(!O||ue)return;let we=c.find(Te=>Te.id===O)||z.find(Te=>Te.id===O);we&&(J(Te=>Te.some(W=>W.id===we.id)?Te:[...Te,we]),N(""))},j=O=>{ue||J(we=>we.filter(Te=>Te.id!==O))},$e=async O=>{if(O.preventDefault(),v||!me.current?.reportValidity())return;if(F.length===0){pe("Select at least one disk.");return}if(!g){let W=F.some(ke=>zt(ke));if(!o){pe("FD.io VPP is not ready. VM creation is blocked until VPP is reachable.");return}if(W&&!a){pe("The thin pool is not ready. Select a non-LVM disk or fix the thin pool first.");return}}let we={name:w.trim(),description:k.trim(),vcpus:Number(T),memory_mb:Number(y),console_enabled:E};g?ue||(we.disk_ids=F.map(W=>W.id),we.interface_ids=z.map(W=>W.id),we.replace_disk_ids=!0,we.replace_interface_ids=!0):(we.disk_ids=F.map(W=>W.id),z.length>0&&(we.interface_ids=z.map(W=>W.id)));let Te=Number(re);Te&&(we.boot_media_id=Te),we.cgroup_class=_,pe(""),V(!0);try{let W=await M(g?`/api/vms/${t?.id}`:"/api/vms",{method:g?"PUT":"POST",body:JSON.stringify(we)});await Ee("vm",W.id,W.name||w.trim(),H),r(W)}catch(W){pe(W instanceof Error?W.message:String(W))}finally{V(!1)}};return e("dialog",{ref:fe,id:"vm-dialog","aria-labelledby":"dialog-title",onClose:f,onClick:O=>{O.target===O.currentTarget&&_e()},onCancel:O=>{v&&O.preventDefault()},children:e("form",{ref:me,novalidate:!0,onSubmit:O=>{$e(O)},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:g?"Edit instance":"New instance"}),e("h2",{id:"dialog-title",children:g?`Edit ${t?.name}`:"Create VM"})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close dialog",onClick:_e,children:"\xD7"})]}),e("div",{class:"form-grid",children:[e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Name"}),e("input",{ref:ne,type:"text",required:!0,maxlength:64,pattern:dr,autocomplete:"off",spellcheck:!1,placeholder:"web-01",value:w,onInput:O=>d(O.currentTarget.value)}),e("span",{class:"field-hint",children:"Letters, digits, dots, dashes and underscores."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Power"}),e("input",{type:"text",value:g?L:"Starts automatically",disabled:!0}),e("span",{class:"field-hint",children:g?"Managed by Start/Stop on the inventory table.":"New VMs start right away; use Stop on the inventory table to shut one down."})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"vCPUs"}),e("input",{type:"number",required:!0,min:1,max:128,step:1,value:T,onInput:O=>m(O.currentTarget.value)})]}),e("label",{class:"field",children:[e("span",{class:"field-label",children:"Memory (MB)"}),e("input",{type:"number",required:!0,min:128,max:1048576,step:128,value:y,onInput:O=>C(O.currentTarget.value)})]}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:"Disks"}),e("div",{class:"interface-picker",children:[e("select",{value:se,disabled:ue||Z.length===0,onChange:O=>ge(O.currentTarget.value),children:[e("option",{value:"",children:Z.length>0?"Select a disk\u2026":"No disks available"}),Z.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",An(O.size_bytes||O.size_gb*1024**3),O.source==="host-device"?" \xB7 passthrough":O.source==="kouja"?" \xB7 kouja":""]},O.id))]}),e("button",{class:"button ghost",type:"button",disabled:ue||Z.length===0,onClick:oe,children:"Add"})]}),e("ul",{class:"interface-list",children:F.length===0?e("li",{class:"empty",children:"No disks selected"}):F.map((O,we)=>e("li",{children:[e("span",{class:"interface-list-name",children:[O.name,e("span",{class:"interface-list-network",children:[An(O.size_bytes||O.size_gb*1024**3),O.source==="host-device"?" \xB7 passthrough":O.source==="kouja"?" \xB7 kouja":"",we===0?" \xB7 boot":""]})]}),e("button",{class:"row-action danger",type:"button",disabled:ue,onClick:()=>he(O.id),children:"Remove"})]},O.id))}),e("span",{class:"field-hint",children:X})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Boot media ",e("em",{children:"optional"})]}),e("select",{value:re,onChange:O=>x(O.currentTarget.value),children:[e("option",{value:"",children:"Boot from disk"}),s.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",An(O.size_bytes)]},O.id))]}),e("span",{class:"field-hint",children:"Read-only ISO from the Installation media tab. You can change this later."})]}),e("label",{class:"field field-wide checkbox-field",children:[e("span",{class:"field-label",children:"Console access"}),e("span",{class:"checkbox-row",children:[e("input",{type:"checkbox",checked:E,onChange:O=>P(O.currentTarget.checked)}),e("span",{children:"Allow end users to open this VM's console"})]}),e("span",{class:"field-hint",children:"When disabled, the console page and VNC WebSocket are blocked."})]}),e("div",{class:"field field-wide cgroup-vm-section",children:e("label",{class:"field",children:[e("span",{class:"field-label",children:["Cgroup class ",e("em",{children:"optional"})]}),e("select",{value:_,onChange:O=>R(O.currentTarget.value),children:[e("option",{value:"",children:"Shared cgroup (host defaults)"}),!Q.some(O=>O.name===_)&&_?e("option",{value:_,children:[_," (not a leaf class)"]}):null,Q.map(O=>e("option",{value:O.name,children:O.name},O.name))]}),e("span",{class:"field-hint",children:["Select a class defined in Settings. Changes are applied immediately to a running VM by moving its QEMU process into the class cgroup.",A&&!A.enabled?" Host-wide cgroup limits are disabled in Settings; the class applies when host cgroup limits are enabled.":""]})]})}),e("div",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Network interfaces ",e("em",{children:"optional"})]}),e("div",{class:"interface-picker",children:[e("select",{value:p,disabled:ue||q.length===0,onChange:O=>N(O.currentTarget.value),children:[e("option",{value:"",children:q.length>0?"Select an interface\u2026":"No interfaces available"}),q.map(O=>e("option",{value:String(O.id),children:[O.name," \xB7 ",O.network_name]},O.id))]}),e("button",{class:"button ghost",type:"button",disabled:ue||q.length===0,onClick:ve,children:"Add"})]}),e("ul",{class:"interface-list",children:z.length===0?e("li",{class:"empty",children:"No interfaces selected"}):z.map(O=>e("li",{children:[e("span",{class:"interface-list-name",children:[O.name,e("span",{class:"interface-list-network",children:O.network_name})]}),e("button",{class:"row-action danger",type:"button",disabled:ue,onClick:()=>j(O.id),children:"Remove"})]},O.id))}),e("span",{class:"field-hint",children:$})]}),e("label",{class:"field field-wide",children:[e("span",{class:"field-label",children:["Description ",e("em",{children:"optional"})]}),e("textarea",{rows:3,maxlength:2e3,placeholder:"What is this instance for?",value:k,onInput:O=>h(O.currentTarget.value)})]}),e("div",{class:"field field-wide",children:e(Le,{labels:H,onChange:G,disabled:v})})]}),ie?e("p",{class:"form-error",role:"alert",children:ie}):null,e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:v,onClick:_e,children:"Cancel"}),e("button",{class:"button primary",type:"submit",disabled:v,children:v?g?"Saving\u2026":"Creating\u2026":g?"Save changes":"Create VM"})]})]})})}function An(t){let n=Number(t)||0;return n>=1024**3?`${(n/1024**3).toFixed(n%1024**3===0?0:1)} GB`:n>=1024**2?`${(n/1024**2).toFixed(n%1024**2===0?0:1)} MB`:n>=1024?`${(n/1024).toFixed(n%1024===0?0:1)} KB`:`${n} B`}function ao({vm:t,onClose:n}){let[s,c]=l(null),[a,o]=l([]),[f,r]=l(""),[g,w]=l(!1),d=ee(!1),k=ee(null),h=ee(null),L=ee({}),[T,m]=l({}),y=ee(null),C=ee(null),E=ee(null),P=ee(null),A=async()=>{if(!d.current){d.current=!0,w(!0);try{let H=await M(`/api/vms/${encodeURIComponent(t.id)}/metrics`),G=Date.now(),re=Number(H.cpu_seconds_total)||0,x=Number(H.disk_read_bytes_total)||0,F=Number(H.disk_write_bytes_total)||0,B=Number(H.network_rx_bytes_total)||0,z=Number(H.network_tx_bytes_total)||0,J=h.current,se=null,ge=null,p=null,N=null,X=null;if(J&&G>J.at){let ae=(G-J.at)/1e3;re>=J.cpu&&(se=(re-J.cpu)/ae*100),x>=J.read&&(ge=(x-J.read)/ae),F>=J.write&&(p=(F-J.write)/ae),B>=J.netRead&&(N=(B-J.netRead)/ae),z>=J.netWrite&&(X=(z-J.netWrite)/ae)}if(!H.running)h.current=null,L.current={},o([]),m({});else{h.current={at:G,cpu:re,read:x,write:F,netRead:B,netWrite:z};let ae={},$={};for(let S of H.block_devices??[]){let v=S.device,V=Number(S.read_bytes)||0,ie=Number(S.write_bytes)||0,pe=L.current[v];if(pe&&G>pe.at){let fe=(G-pe.at)/1e3;ae[v]={read:Math.max(0,(V-pe.read)/fe),write:Math.max(0,(ie-pe.write)/fe)}}$[v]={at:G,read:V,write:ie}}L.current=$,m(ae),o(S=>{let v=[...S,{cpu:se,rss:Number(H.memory_rss_bytes)||0,read:ge,write:p,netRead:N,netWrite:X}];return v.length>60?v.slice(v.length-60):v})}c(H),r("")}catch(H){r(`Could not read VM metrics: ${H instanceof Error?H.message:String(H)}`)}finally{d.current=!1,w(!1)}}};K(()=>{k.current?.showModal(),A();let H=window.setInterval(()=>{document.hidden||A()},2e3);return()=>{window.clearInterval(H)}},[]),K(()=>{let H=a.map(N=>N.cpu),G=a.map(N=>N.rss),re=a.map(N=>N.read),x=a.map(N=>N.write),F=a.map(N=>N.netRead),B=a.map(N=>N.netWrite),z=H.filter(N=>Number.isFinite(N)),J=G.filter(N=>Number.isFinite(N)),se=[...re,...x].filter(N=>Number.isFinite(N)),ge=[...F,...B].filter(N=>Number.isFinite(N)),p=Math.max(0,Number(t.memory_mb||0)*1024*1024);wt(y.current,[{values:H,color:"#60a5fa"}],{max:Math.max(100,z.length?Math.max(...z)*1.15:0),format:N=>`${Math.round(N)}%`}),wt(C.current,[{values:G,color:"#34d399"}],{max:Math.max(p,J.length?Math.max(...J)*1.15:0),marker:p,format:Ue}),wt(E.current,[{values:re,color:"#f59e0b"},{values:x,color:"#f472b6"}],{max:se.length?Math.max(...se)*1.15:0,format:N=>`${Ue(N)}/s`}),wt(P.current,[{values:F,color:"#38bdf8"},{values:B,color:"#a78bfa"}],{max:ge.length?Math.max(...ge)*1.15:0,format:N=>`${Ue(N)}/s`})},[a,t.memory_mb]);let b=()=>{k.current?.close()},_=s,R=!!_?.running;return e("dialog",{ref:k,id:"vm-monitor-dialog","aria-labelledby":"vm-monitor-title",onClose:n,onClick:H=>{H.target===H.currentTarget&&b()},children:[e("header",{class:"dialog-head",children:[e("div",{children:[e("p",{class:"eyebrow",children:"Live telemetry"}),e("h2",{id:"vm-monitor-title",children:["VM monitor \xB7 ",t.name]})]}),e("button",{class:"icon-button",type:"button","aria-label":"Close monitor",onClick:b,children:"\xD7"})]}),e("p",{class:"dialog-lede",children:"QEMU process metrics"}),f?e("div",{class:"banner",role:"alert",children:f}):null,e("div",{class:"monitor-grid",children:[e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Process"}),e("strong",{class:"metric-value","data-status":R?"running":"stopped",children:R?"running":"stopped"}),e("span",{class:"metric-detail",children:["PID ",_?.pid||"\u2014"]})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"CPU"}),e("strong",{class:"metric-value",children:R?a.length<=1?"sampling\u2026":`${(a[a.length-1]?.cpu??0).toFixed(1)}%`:"\u2014"}),e("span",{class:"metric-detail",children:_?`${_.vcpus} vCPU`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Memory"}),e("strong",{class:"metric-value",children:R?Ue(Number(_?.memory_rss_bytes)||0):"\u2014"}),e("span",{class:"metric-detail",children:_?`${Gt(_.memory_mb)} configured${R?` \xB7 ${Ue(Number(_.memory_virtual_bytes)||0)} virtual`:""}`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Disk read"}),e("strong",{class:"metric-value",children:R?a.length<=1?"sampling\u2026":`${Ue(a[a.length-1]?.read??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:_?`${Ue(Number(_.disk_read_bytes_total)||0)} total`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Disk write"}),e("strong",{class:"metric-value",children:R?a.length<=1?"sampling\u2026":`${Ue(a[a.length-1]?.write??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:_?`${Ue(Number(_.disk_write_bytes_total)||0)} total`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Network I/O"}),e("strong",{class:"metric-value",children:R?a.length<=1?"sampling\u2026":`\u2193 ${Ue(a[a.length-1]?.netRead??0)}/s \xB7 \u2191 ${Ue(a[a.length-1]?.netWrite??0)}/s`:"\u2014"}),e("span",{class:"metric-detail",children:_?`RX ${Ue(Number(_.network_rx_bytes_total)||0)} \xB7 TX ${Ue(Number(_.network_tx_bytes_total)||0)}`:"\u2014"})]}),e("section",{class:"monitor-card",children:[e("span",{class:"metric-label",children:"Uptime"}),e("strong",{class:"metric-value",children:R&&_?no(Number(_.uptime_seconds)||0):"\u2014"}),e("span",{class:"metric-detail",children:"since QEMU started"})]})]}),e("div",{class:"monitor-charts",children:[e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"CPU history"}),e("span",{class:"chart-note",children:"last 60 samples"})]}),e("canvas",{ref:y,class:"metric-chart",role:"img","aria-label":"CPU usage history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Memory RSS"}),e("span",{class:"chart-note",children:"last 60 samples"})]}),e("canvas",{ref:C,class:"metric-chart",role:"img","aria-label":"Memory RSS history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Disk I/O"}),e("span",{class:"chart-legend",children:[e("i",{class:"legend-read"}),"read ",e("i",{class:"legend-write"}),"write"]})]}),e("canvas",{ref:E,class:"metric-chart",role:"img","aria-label":"Disk read and write history"})]}),e("section",{class:"chart-card",children:[e("div",{class:"chart-head",children:[e("span",{children:"Network I/O"}),e("span",{class:"chart-legend",children:[e("i",{class:"legend-net-rx"}),"RX ",e("i",{class:"legend-net-tx"}),"TX"]})]}),e("canvas",{ref:P,class:"metric-chart",role:"img","aria-label":"Network receive and transmit history"})]})]}),e("section",{class:"monitor-block-devices",children:[e("div",{class:"chart-head",children:[e("span",{children:"Block devices"}),e("span",{class:"chart-note",children:"QEMU blockstats \xB7 cumulative totals and current rates"})]}),_?.block_devices?.length?e("div",{class:"table-wrap",children:e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Device"}),e("th",{scope:"col",children:"Read"}),e("th",{scope:"col",children:"Write"}),e("th",{scope:"col",children:"Operations"}),e("th",{scope:"col",children:"Errors"})]})}),e("tbody",{children:_.block_devices.map(H=>{let G=T[H.device],re=Number(H.read_bytes)||0,x=Number(H.write_bytes)||0,F=(Number(H.failed_read_operations)||0)+(Number(H.failed_write_operations)||0)+(Number(H.invalid_read_operations)||0)+(Number(H.invalid_write_operations)||0);return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:H.name||H.device}),e("span",{class:"vm-description mono",children:H.device})]}),e("td",{class:"mono",children:[Ue(re)," total",G?e("span",{class:"vm-description",children:[Ue(G.read),"/s"]}):null]}),e("td",{class:"mono",children:[Ue(x)," total",G?e("span",{class:"vm-description",children:[Ue(G.write),"/s"]}):null]}),e("td",{class:"mono",children:e("span",{class:"vm-description",children:["read ",Number(H.read_operations)||0," \xB7 write ",Number(H.write_operations)||0]})}),e("td",{class:F?"mono":"mono muted",children:[F,e("span",{class:"vm-description",children:["failed read ",Number(H.failed_read_operations)||0," \xB7 failed write"," ",Number(H.failed_write_operations)||0," \xB7 invalid"," ",Number(H.invalid_read_operations)||0,"/",Number(H.invalid_write_operations)||0]})]})]},H.device)})})]})}):e("p",{class:"field-hint",children:"No block devices reported by QEMU."})]}),e("p",{class:"field-hint",children:_&&R?`Sampled ${new Date(_.sampled_at).toLocaleTimeString()}`:"VM is not running."}),e("footer",{class:"dialog-actions",children:[e("button",{class:"button ghost",type:"button",disabled:g,onClick:()=>{A()},children:g?"Refreshing\u2026":"Refresh"}),e("button",{class:"button primary",type:"button",onClick:b,children:"Close"})]})]})}var oo=new Intl.NumberFormat;function fr(t){if(!t)return"\u2014";let n=new Date(t);return Number.isNaN(n.getTime())?"\u2014":n.toLocaleString(void 0,{dateStyle:"medium",timeStyle:"short"})}function mr(t){return Array.isArray(t.disks)&&t.disks.length>0?t.disks:t.disk?[t.disk]:[]}function io(){let[t,n]=l([]),[s,c]=l([]),[a,o]=l([]),[f,r]=l([]),[g,w]=l(!1),[d,k]=l(!1),[h,L]=l(!0),[T,m]=l(""),[y,C]=l(null),[E,P]=l(null),A=Me("vm"),b=je();K(()=>{if(b.segments[0]!=="vms"){C(null),P(null);return}let S=b.segments[1],v=b.segments[2];if(S==="new"){P(null),C({vm:null});return}if(S&&S!=="new"){let V=t.find(ie=>ie.id===S);if(V&&v==="edit"){P(null),C({vm:V});return}if(V&&v==="monitor"){C(null),P(V);return}}C(null),P(null)},[b.path,t]);let _=S=>{n(S),window.dispatchEvent(new CustomEvent("rookery:vms-changed",{detail:S}))},R=async(S=!0)=>{try{let v=await M("/api/vms");_(Array.isArray(v)?v:[]),S&&m("")}catch(v){S&&m(v instanceof Error?v.message:String(v))}},H=async()=>{try{let S=await M("/api/disks");c(Array.isArray(S)?S:[])}catch{c([])}},G=async()=>{try{let S=await M("/api/media");o(Array.isArray(S)?S:[])}catch{o([])}},re=async()=>{try{let S=await M("/api/interfaces");r(Array.isArray(S)?S:[])}catch{r([])}},x=async()=>{try{let S=await M("/api/status");w(!!S.thin_pool?.ready),k(!!S.vpp?.ready)}catch{w(!1),k(!1)}},F=async()=>{L(!0),await Promise.allSettled([R(),H(),G(),re(),x()]),L(!1)};K(()=>{F();let S=me=>{let ue=me.detail;Array.isArray(ue)&&c(ue)},v=me=>{let ue=me.detail;Array.isArray(ue)&&o(ue)},V=me=>{let ue=me.detail;Array.isArray(ue)&&r(ue)},ie=me=>{let ue=me.detail;ue&&typeof ue.thinPool=="boolean"&&w(ue.thinPool)},pe=me=>{let ue=me.detail;typeof ue=="boolean"&&k(ue)},fe=me=>{me.detail==="vms"&&F()},ne=()=>{te("/vms/new")};return window.addEventListener("rookery:disks-changed",S),window.addEventListener("rookery:media-changed",v),window.addEventListener("rookery:interfaces-changed",V),window.addEventListener("rookery:readiness-changed",ie),window.addEventListener("rookery:vpp-ready",pe),window.addEventListener("rookery:tab-changed",fe),window.addEventListener("rookery:add-vm",ne),()=>{window.removeEventListener("rookery:disks-changed",S),window.removeEventListener("rookery:media-changed",v),window.removeEventListener("rookery:interfaces-changed",V),window.removeEventListener("rookery:readiness-changed",ie),window.removeEventListener("rookery:vpp-ready",pe),window.removeEventListener("rookery:tab-changed",fe),window.removeEventListener("rookery:add-vm",ne)}},[]),K(()=>{let S=`${t.length} VM${t.length===1?"":"s"}`,v=document.getElementById("vm-count");v&&(v.textContent=S);let V=document.getElementById("vm-tab-count");V&&(V.textContent=String(t.length))},[t.length]),K(()=>{if(b.segments[0]!=="vms")return;let S=window.setInterval(()=>{document.hidden||R(!1)},5e3);return()=>window.clearInterval(S)},[b.path]);let B=s.filter(S=>!S.attached_vm_id&&!S.is_template&&!S.deletion_state),z=B.some(S=>!zt(S));K(()=>{let S=document.getElementById("new-vm"),v=document.getElementById("new-vm-hint");if(!S||!v)return;let V="";d?B.length===0?V="Create an unattached disk on the Disks tab first.":!g&&!z&&(V="VM creation is blocked until the thin pool is ready or a non-LVM disk is available."):V="VM creation is blocked until FD.io VPP is ready.",S.disabled=V!=="",S.title=V,v.textContent=V,v.hidden=V===""},[B.length,z,g,d]);let J=()=>{if(!d){le("VM creation is blocked until FD.io VPP is ready.",!0);return}if(B.length===0){le("Create an unattached disk on the Disks tab first.",!0);return}if(!g&&!z){le("The thin pool is not ready and no non-LVM disk is available.",!0);return}C({vm:null}),te("/vms/new")},se=async S=>{if(window.confirm(`Delete "${S.name}"? -This removes the inventory record only. The disk remains available.`))try{await V(`/api/vms/${v.id}`,{method:"DELETE"}),m(""),oe(`Deleted ${v.name}`),await Promise.allSettled([H(),G(),ne()])}catch($){m(`Could not delete ${v.name}: ${$ instanceof Error?$.message:String($)}`)}},ae=async v=>{try{let $=await V(`/api/vms/${v.id}/start`,{method:"POST"});T(t.map(C=>C.id===$.id?$:C)),m(""),oe(`Started ${v.name}`),G()}catch($){m(`Could not start ${v.name}: ${$ instanceof Error?$.message:String($)}`),H()}},E=async v=>{try{let $=await V(`/api/vms/${v.id}/stop`,{method:"POST"});T(t.map(C=>C.id===$.id?$:C)),m(""),oe(`Stopped ${v.name}`),G()}catch($){m(`Could not stop ${v.name}: ${$ instanceof Error?$.message:String($)}`),H()}},M=v=>{if(!v.console_enabled){oe("Console access is disabled. Edit the VM to enable it.",!0);return}if(v.status!=="running"){oe("Start the VM before opening its console.",!0);return}window.open(`/console.html?vm=${encodeURIComponent(v.id)}`,"_blank","noopener")},se=t.filter(v=>v.status==="running").length,ce=t.length===0?h?"Loading instances\u2026":"No instances registered":`${t.length} instance${t.length===1?"":"s"} \xB7 ${se} running`;return e(ge,{children:[e("section",{class:"panel","aria-labelledby":"inventory-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"inventory-heading",children:"Inventory"}),e("p",{class:"panel-subtitle",children:ce})]}),e("button",{class:"button ghost",type:"button",disabled:h,onClick:()=>{R()},children:h?"Refreshing\u2026":"Refresh"})]}),I?e("div",{class:"banner",role:"alert",children:I}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"vCPU"}),e("th",{scope:"col",children:"Memory"}),e("th",{scope:"col",children:"Cgroup"}),e("th",{scope:"col",children:"Disks"}),e("th",{scope:"col",children:"Boot"}),e("th",{scope:"col",children:"Console"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(v=>{let $=ur(v),C=a.find(ie=>ie.id===v.boot_media_id)||v.boot_media,U=Array.isArray(v.interfaces)?v.interfaces:[],de=v.status==="running"||v.status==="starting";return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:v.name}),v.description?e("span",{class:"vm-description",title:v.description,children:v.description}):null,e(De,{entityType:"vm",entityID:v.id,name:v.name,labels:N.labels[v.id],onSaved:ie=>N.setEntityLabels(v.id,ie)})]}),e("td",{children:e("span",{class:"status-badge","data-status":v.status,children:v.status})}),e("td",{class:"mono",children:no.format(v.vcpus)}),e("td",{class:"mono",children:zt(v.memory_mb)}),e("td",{class:v.cgroup_class?"mono":"mono muted",title:v.cgroup_class?`Cgroup class: ${v.cgroup_class}`:"Shared cgroup",children:v.cgroup_class||"shared"}),e("td",{class:$.length?"mono":"mono muted",title:$.length?$.map(ie=>`${ie.name} \xB7 ${no.format(ie.size_gb)} GB`).join(", "):void 0,children:$.length?$.map(ie=>ie.name).join(", "):"\u2014"}),e("td",{class:C?"mono":"mono muted",title:C?.path||void 0,children:C?C.name:"disk"}),e("td",{class:v.console_enabled?"mono":"mono muted",children:v.console_enabled?"enabled":"disabled"}),e("td",{class:U.length?"mono":"mono muted",title:U.length?U.map(ie=>`${ie.name} (${ie.network_name})`).join(", "):void 0,children:U.length?U.map(ie=>ie.network_name||ie.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:cr(v.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:de?"row-action danger":"row-action",type:"button",onClick:()=>de?void E(v):void ae(v),children:de?"Stop":"Start"}),e("button",{class:"row-action",type:"button",disabled:!v.console_enabled||v.status!=="running",title:v.console_enabled?v.status!=="running"?"Start the VM to open its console.":"":"Console access is disabled. Edit the VM to enable it.",onClick:()=>M(v),children:"Console"}),e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vms/${v.id}/monitor`),children:"Monitor"}),e("button",{class:"row-action",type:"button",onClick:()=>Q(`/vms/${v.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{y(v)},children:"Delete"})]})})]},v.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2B21"}),e("h3",{children:"No virtual machines yet"}),e("p",{children:"Create the first instance to start building out this host."}),e("button",{class:"button primary",type:"button",disabled:F.length===0,title:F.length===0?"Create an unattached disk on the Disks tab first.":"",onClick:Y,children:"Create a VM"})]})})]}),g?e(eo,{vm:g.vm,disks:s,media:a,interfaces:f,thinPoolReady:b,vppReady:u,onClose:()=>{P(null),Q("/vms",!0)},onSaved:v=>{P(null),Q("/vms",!0),m(""),oe(`${g.vm?"Saved":"Created"} ${v.name}`),Promise.allSettled([H(),G(),ne()])}},g.vm?`edit-${g.vm.id}`:"new"):null,L?e(to,{vm:L,onClose:()=>{S(null),Q("/vms",!0)}},`monitor-${L.id}`):null]})}function ao(t){t&&Fe(e(so,{}),t)}var oo=document.getElementById("toast-root");oo&&Cs(oo);ao(document.getElementById("vms-root"));Us(document.getElementById("disks-root"));ta(document.getElementById("media-root"));Ys(document.getElementById("lightvm-root"));Na(document.getElementById("networks-root"));Ya(document.getElementById("vnfs-root"));Wa(document.getElementById("settings-root"));var io=document.getElementById("client-certs-root");io&&Fe(os(As,{}),io);Ns(); +This removes the inventory record only. The disk remains available.`))try{await M(`/api/vms/${S.id}`,{method:"DELETE"}),m(""),le(`Deleted ${S.name}`),await Promise.allSettled([R(),H(),re()])}catch(v){m(`Could not delete ${S.name}: ${v instanceof Error?v.message:String(v)}`)}},ge=async S=>{try{let v=await M(`/api/vms/${S.id}/start`,{method:"POST"});_(t.map(V=>V.id===v.id?v:V)),m(""),le(`Started ${S.name}`),H()}catch(v){m(`Could not start ${S.name}: ${v instanceof Error?v.message:String(v)}`),R()}},p=async S=>{try{let v=await M(`/api/vms/${S.id}/stop`,{method:"POST"});_(t.map(V=>V.id===v.id?v:V)),m(""),le(`Stopped ${S.name}`),H()}catch(v){m(`Could not stop ${S.name}: ${v instanceof Error?v.message:String(v)}`),R()}},N=async S=>{try{let v=await M(`/api/vms/${S.id}/resume`,{method:"POST"});_(t.map(V=>V.id===v.id?v:V)),m(""),le(`Resumed ${S.name}`)}catch(v){m(`Could not resume ${S.name}: ${v instanceof Error?v.message:String(v)}`),R()}},X=S=>{if(!S.console_enabled){le("Console access is disabled. Edit the VM to enable it.",!0);return}if(S.status!=="running"){le("Start the VM before opening its console.",!0);return}window.open(`/console.html?vm=${encodeURIComponent(S.id)}`,"_blank","noopener")},ae=t.filter(S=>S.status==="running").length,$=t.length===0?h?"Loading instances\u2026":"No instances registered":`${t.length} instance${t.length===1?"":"s"} \xB7 ${ae} running`;return e(ye,{children:[e("section",{class:"panel","aria-labelledby":"inventory-heading",children:[e("div",{class:"panel-head",children:[e("div",{children:[e("h2",{id:"inventory-heading",children:"Inventory"}),e("p",{class:"panel-subtitle",children:$})]}),e("button",{class:"button ghost",type:"button",disabled:h,onClick:()=>{F()},children:h?"Refreshing\u2026":"Refresh"})]}),T?e("div",{class:"banner",role:"alert",children:T}):null,e("div",{class:"table-wrap",children:t.length>0?e("table",{children:[e("thead",{children:e("tr",{children:[e("th",{scope:"col",children:"Name"}),e("th",{scope:"col",children:"Status"}),e("th",{scope:"col",children:"vCPU"}),e("th",{scope:"col",children:"Memory"}),e("th",{scope:"col",children:"Cgroup"}),e("th",{scope:"col",children:"Disks"}),e("th",{scope:"col",children:"Boot"}),e("th",{scope:"col",children:"Console"}),e("th",{scope:"col",children:"Network"}),e("th",{scope:"col",class:"hide-sm",children:"Updated"}),e("th",{scope:"col",children:e("span",{class:"sr-only",children:"Actions"})})]})}),e("tbody",{children:t.map(S=>{let v=mr(S),V=a.find(ne=>ne.id===S.boot_media_id)||S.boot_media,ie=Array.isArray(S.interfaces)?S.interfaces:[],pe=S.status==="running"||S.status==="starting",fe=!!S.run_state?.startsWith("paused");return e("tr",{children:[e("td",{children:[e("span",{class:"vm-name",children:S.name}),S.description?e("span",{class:"vm-description",title:S.description,children:S.description}):null,e(Ve,{entityType:"vm",entityID:S.id,name:S.name,labels:A.labels[S.id],onSaved:ne=>A.setEntityLabels(S.id,ne)})]}),e("td",{children:e("span",{class:"status-badge","data-status":fe?"paused":S.status,children:fe?S.run_state:S.status})}),e("td",{class:"mono",children:oo.format(S.vcpus)}),e("td",{class:"mono",children:Gt(S.memory_mb)}),e("td",{class:S.cgroup_class?"mono":"mono muted",title:S.cgroup_class?`Cgroup class: ${S.cgroup_class}`:"Shared cgroup",children:S.cgroup_class||"shared"}),e("td",{class:v.length?"mono":"mono muted",title:v.length?v.map(ne=>`${ne.name} \xB7 ${oo.format(ne.size_gb)} GB`).join(", "):void 0,children:v.length?v.map(ne=>ne.name).join(", "):"\u2014"}),e("td",{class:V?"mono":"mono muted",title:V?.path||void 0,children:V?V.name:"disk"}),e("td",{class:S.console_enabled?"mono":"mono muted",children:S.console_enabled?"enabled":"disabled"}),e("td",{class:ie.length?"mono":"mono muted",title:ie.length?ie.map(ne=>`${ne.name} (${ne.network_name})`).join(", "):void 0,children:ie.length?ie.map(ne=>ne.network_name||ne.name).join(", "):"none"}),e("td",{class:"updated hide-sm",children:fr(S.updated_at)}),e("td",{children:e("div",{class:"actions",children:[e("button",{class:pe?"row-action danger":"row-action",type:"button",onClick:()=>pe?void p(S):void ge(S),children:pe?"Stop":"Start"}),fe?e("button",{class:"row-action",type:"button",onClick:()=>{N(S)},children:"Resume"}):null,e("button",{class:"row-action",type:"button",disabled:!S.console_enabled||S.status!=="running",title:S.console_enabled?S.status!=="running"?"Start the VM to open its console.":"":"Console access is disabled. Edit the VM to enable it.",onClick:()=>X(S),children:"Console"}),e("button",{class:"row-action",type:"button",onClick:()=>te(`/vms/${S.id}/monitor`),children:"Monitor"}),e("button",{class:"row-action",type:"button",onClick:()=>te(`/vms/${S.id}/edit`),children:"Edit"}),e("button",{class:"row-action danger",type:"button",onClick:()=>{se(S)},children:"Delete"})]})})]},S.id)})})]}):e("div",{class:"empty",children:[e("div",{class:"empty-mark","aria-hidden":"true",children:"\u2B21"}),e("h3",{children:"No virtual machines yet"}),e("p",{children:"Create the first instance to start building out this host."}),e("button",{class:"button primary",type:"button",disabled:B.length===0,title:B.length===0?"Create an unattached disk on the Disks tab first.":"",onClick:J,children:"Create a VM"})]})})]}),y?e(so,{vm:y.vm,disks:s,media:a,interfaces:f,thinPoolReady:g,vppReady:d,onClose:()=>{C(null),te("/vms",!0)},onSaved:S=>{C(null),te("/vms",!0),m(""),le(`${y.vm?"Saved":"Created"} ${S.name}`),Promise.allSettled([R(),H(),re()])}},y.vm?`edit-${y.vm.id}`:"new"):null,E?e(ao,{vm:E,onClose:()=>{P(null),te("/vms",!0)}},`monitor-${E.id}`):null]})}function ro(t){t&&Oe(e(io,{}),t)}var lo=document.getElementById("toast-root");lo&&Ss(lo);ro(document.getElementById("vms-root"));Gs(document.getElementById("disks-root"));aa(document.getElementById("media-root"));ta(document.getElementById("lightvm-root"));Ma(document.getElementById("networks-root"));to(document.getElementById("vnfs-root"));Ka(document.getElementById("settings-root"));var co=document.getElementById("client-certs-root");co&&Oe(is(Ds,{}),co);As(); diff --git a/internal/webui/static/style.css b/internal/webui/static/style.css index 27c56f5..ef34df5 100644 --- a/internal/webui/static/style.css +++ b/internal/webui/static/style.css @@ -1213,6 +1213,46 @@ dialog form { gap: 1rem; } +.connection-section { + grid-column: 1 / -1; + min-width: 0; + margin: 0; + padding: 0.85rem 1rem 1rem; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.012); +} + +.connection-section legend { + padding: 0 0.4rem; + color: var(--text); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.connection-section .field { + margin-top: 0.15rem; +} + +.connection-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.9rem; +} + +.connection-status { + color: var(--muted); + font-size: 0.75rem; + overflow-wrap: anywhere; +} + +.connection-status.error { + color: var(--danger); +} + .route-page-head { display: flex; justify-content: space-between; @@ -1877,6 +1917,14 @@ textarea:disabled { } +.monitor-block-devices { + margin-top: 1.25rem; +} + +.monitor-block-devices .vm-description { + display: block; +} + .monitor-charts { display: grid; grid-template-columns: 1fr; diff --git a/ui/src/inventory/disks/DiskBandwidthDialog.tsx b/ui/src/inventory/disks/DiskBandwidthDialog.tsx new file mode 100644 index 0000000..a629498 --- /dev/null +++ b/ui/src/inventory/disks/DiskBandwidthDialog.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { api } from "../../shared/api"; +import type { Disk } from "../types"; + +type DiskBandwidthDialogProps = { + disk: Disk; + onClose: () => void; + onSaved: (disk: Disk) => void; +}; + +export function DiskBandwidthDialog({ disk, onClose, onSaved }: DiskBandwidthDialogProps) { + const [readBPS, setReadBPS] = useState(String(disk.read_bps ?? 0)); + const [writeBPS, setWriteBPS] = useState(String(disk.write_bps ?? 0)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const dialogRef = useRef(null); + + useEffect(() => { + dialogRef.current?.showModal(); + }, []); + + const close = () => { + if (!saving) { + dialogRef.current?.close(); + } + }; + + const submit = async (event: Event) => { + event.preventDefault(); + if (saving) { + return; + } + const read = Number(readBPS) || 0; + const write = Number(writeBPS) || 0; + if (read < 0 || write < 0) { + setError("Bandwidth limits must be zero or greater."); + return; + } + setSaving(true); + setError(""); + try { + const saved = await api(`/api/disks/${disk.id}`, { + method: "PUT", + body: JSON.stringify({ read_bps: read, write_bps: write }), + }); + onSaved(saved); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + return ( + { + if (event.target === event.currentTarget) { + close(); + } + }} + onCancel={(event) => { + if (saving) { + event.preventDefault(); + } + }} + > +
void submit(event)}> +
+
+

Disk limits

+

{disk.name}

+
+ +
+
+ + +

+ 0 means unlimited. QEMU applies these limits when the disk is attached to a VM start; + a running VM keeps its current limits until it is restarted. +

+ {error ? ( + + ) : null} +
+
+ + +
+
+
+ ); +} diff --git a/ui/src/inventory/disks/DiskDialog.tsx b/ui/src/inventory/disks/DiskDialog.tsx index 6e44d55..fd60087 100644 --- a/ui/src/inventory/disks/DiskDialog.tsx +++ b/ui/src/inventory/disks/DiskDialog.tsx @@ -17,6 +17,8 @@ export function DiskDialog({ backends, disks, onClose, onSaved }: DiskDialogProp const [storageKind, setStorageKind] = useState<"lvm" | "host">(backends.length === 0 ? "host" : "lvm"); const [name, setName] = useState(""); const [sizeGB, setSizeGB] = useState("10"); + const [readBPS, setReadBPS] = useState("0"); + const [writeBPS, setWriteBPS] = useState("0"); const [backendID, setBackendID] = useState(defaultBackend ? String(defaultBackend.id) : ""); const [hostDevices, setHostDevices] = useState([]); const [hostDevicePath, setHostDevicePath] = useState(""); @@ -118,11 +120,13 @@ export function DiskDialog({ backends, disks, onClose, onSaved }: DiskDialogProp try { const body = storageKind === "host" - ? { name: name.trim(), host_device: hostDevice?.path } + ? { name: name.trim(), host_device: hostDevice?.path, read_bps: Number(readBPS) || 0, write_bps: Number(writeBPS) || 0 } : { name: name.trim(), size_gb: Number(sizeGB), storage_backend_id: Number(backendID), + read_bps: Number(readBPS) || 0, + write_bps: Number(writeBPS) || 0, ...(source === "template" && template ? { template_disk_id: template.id } : {}), ...(source === "template" && template?.provisioning_script && provisioning !== undefined ? { provisioning } @@ -205,16 +209,40 @@ export function DiskDialog({ backends, disks, onClose, onSaved }: DiskDialogProp onChange={(event) => setStorageKind(event.currentTarget.value as "lvm" | "host")} > - Passthrough disks hand an existing host disk, partition, or logical volume to one VM. - Rookery never deletes or resizes the underlying device. + Provisioned disks use the selected storage backend. Passthrough disks hand an + existing host disk, partition, or logical volume to one VM; Rookery never deletes + or resizes the underlying device. + + {storageKind === "lvm" ? ( <> {supportsTemplates ? ( diff --git a/ui/src/inventory/disks/DisksTab.tsx b/ui/src/inventory/disks/DisksTab.tsx index 45935e4..daac858 100644 --- a/ui/src/inventory/disks/DisksTab.tsx +++ b/ui/src/inventory/disks/DisksTab.tsx @@ -4,6 +4,7 @@ import { showToast } from "../../shared/toast"; import { EntityLabels, useEntityLabels } from "../../shared/labels"; import type { Disk, StorageBackend } from "../types"; import { DiskDialog } from "./DiskDialog"; +import { DiskBandwidthDialog } from "./DiskBandwidthDialog"; import { ImportDisksDialog } from "./ImportDisksDialog"; import { StorageBackendDialog } from "./StorageBackendDialog"; import { TemplateProvisioningDialog } from "./TemplateProvisioningDialog"; @@ -22,6 +23,23 @@ function formatDate(value: string | undefined): string { return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } +function formatRate(bytes: number | undefined): string { + const value = Number(bytes) || 0; + if (value <= 0) { + return "unlimited"; + } + if (value >= 1024 ** 3) { + return `${(value / 1024 ** 3).toFixed(1)} GiB/s`; + } + if (value >= 1024 ** 2) { + return `${(value / 1024 ** 2).toFixed(1)} MiB/s`; + } + if (value >= 1024) { + return `${(value / 1024).toFixed(1)} KiB/s`; + } + return `${value} B/s`; +} + function formatDiskSize(disk: Disk): string { const bytes = Number(disk.size_bytes) || 0; if (bytes <= 0) { @@ -49,12 +67,14 @@ export function DisksTab() { const [backendDialog, setBackendDialog] = useState<{ backend: StorageBackend | null } | null>(null); const [importBackend, setImportBackend] = useState(null); const [provisioningDisk, setProvisioningDisk] = useState(null); + const [bandwidthDisk, setBandwidthDisk] = useState(null); const diskLabels = useEntityLabels("disk"); const backendLabels = useEntityLabels("storage_backend"); const route = useRoute(); useEffect(() => { setProvisioningDisk(null); + setBandwidthDisk(null); }, [route.path, route.search]); useEffect(() => { @@ -174,16 +194,23 @@ export function DisksTab() { const confirmed = window.confirm( disk.source === "host-device" ? `Delete disk "${disk.name}"?\n\nThis removes only the inventory record. The host block device ${disk.device} is not modified.` - : `Delete disk "${disk.name}"?\n\nThis removes the LVM thin volume and its inventory record.` + : disk.source === "kouja" + ? `Delete disk "${disk.name}"?\n\nThis queues the kouja volume for unexport and deletion. The disk stays visible until cleanup finishes.` + : `Delete disk "${disk.name}"?\n\nThis removes the LVM thin volume and its inventory record.` ); if (!confirmed) { return; } try { await api(`/api/disks/${disk.id}`, { method: "DELETE" }); - publishDisks(disks.filter((item) => item.id !== disk.id)); + if (disk.source === "kouja") { + await loadDisks(); + showToast(`Deletion queued for ${disk.name}`); + } else { + publishDisks(disks.filter((item) => item.id !== disk.id)); + showToast(`Deleted disk ${disk.name}`); + } setDiskError(""); - showToast(`Deleted disk ${disk.name}`); void loadBackends(false); } catch (err) { setDiskError(`Could not delete ${disk.name}: ${err instanceof Error ? err.message : String(err)}`); @@ -191,8 +218,8 @@ export function DisksTab() { }; const toggleTemplate = async (disk: Disk) => { - if (disk.source === "host-device") { - showToast("Host block devices cannot be templates.", true); + if (disk.source !== "lvm-thin" && disk.source) { + showToast("Only LVM thin disks can be templates.", true); return; } if (disk.attached_vm_id) { @@ -314,9 +341,11 @@ export function DisksTab() { onSaved={(labels) => backendLabels.setEntityLabels(String(backend.id), labels)} /> - {backend.driver === "lvm-thin" ? "LVM thin" : backend.driver} + {backend.driver === "lvm-thin" ? "LVM thin" : backend.driver === "kouja" ? "Kouja" : backend.driver} - {backend.config?.vg || "—"}/{backend.config?.thin_pool || "—"} + {backend.driver === "kouja" + ? `${backend.config?.kouja?.endpoint || "—"} · ${backend.config?.kouja?.storage_class || "—"}` + : `${backend.config?.vg || "—"}/${backend.config?.thin_pool || "—"}`} {backend.config?.supports_templates ? ( templates ) : null} @@ -419,10 +448,20 @@ export function DisksTab() { {disk.source === "host-device" ? ( passthrough ) : null} + {disk.source === "kouja" ? kouja : null} + {disk.deletion_state ? ( + + {disk.deletion_state === "delete_failed" ? "delete failed" : disk.deletion_state} + + ) : null} {disk.template_disk_name ? ( from {disk.template_disk_name} ) : null} - {disk.device ? ( + {disk.locator ? ( + + {disk.locator} + + ) : disk.device ? ( {disk.device} @@ -435,7 +474,14 @@ export function DisksTab() { onSaved={(labels) => diskLabels.setEntityLabels(String(disk.id), labels)} /> - {formatDiskSize(disk)} + + {formatDiskSize(disk)} + {disk.read_bps || disk.write_bps ? ( + + {formatRate(disk.read_bps)} read · {formatRate(disk.write_bps)} write + + ) : null} + {disk.source === "host-device" ? "host device" : disk.storage_backend_name || "—"} @@ -454,7 +500,20 @@ export function DisksTab() { Provisioning ) : null} - {disk.source === "host-device" ? null : ( + + {disk.source === "host-device" || (disk.source && disk.source !== "lvm-thin") ? null : ( + {connectionMessage ? {connectionMessage} : null} + {connectionError ? {connectionError} : null} + + + +
+ TLS server name + +
+ +
+ CA certificate +