Skip to content

SynVirt — Patchnotes

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/PATCHNOTES.md. Edit at the source, not here.

Living changelog. Every fix lands here with date, area, files touched, summary, and reason. Newest on top. Dates in YYYY-MM-DD.

Conventions:

  • Area is one of: daemon, web-ux, libvirt, console-gateway, iso-builder, installer, tui, docs, ops.
  • Severity is P0 (crashes/broken user flow), P1 (wrong UX), P2 (polish).
  • Scope lists the files touched so a reviewer can spot-check without running git blame.
  • Root cause explains why the bug existed — the one piece that survives the grep.
  • When a fix lands live on a running appliance, note the host under Deployed.

2026-05-01 — 0.9.0-alpha.2 SPICE removal

Section titled “2026-05-01 — 0.9.0-alpha.2 SPICE removal”

console-gateway + synvirt-libvirt + web-ux* + guest-tools-iso · feature · SPICE deleted, VNC is the single console path

Section titled “console-gateway + synvirt-libvirt + web-ux* + guest-tools-iso · feature · SPICE deleted, VNC is the single console path”

Scope: every layer that touched SPICE. crates/console-gateway is now a WebSocket↔TCP bridge for VNC; crates/synvirt-libvirt’s domain XML emitter drops <graphics type='spice'>, <channel type='spicevmc'>, <redirdev>, <redirfilter>, and the qxl VideoModel; the dashboard ships noVNC only (vendored spice-html5 removed); crates/guest-tools-iso no longer stages spice-guest-tools / spice-vdagent. Brand color references using #06b6d4 as primary were corrected to the SYNNET brand #0566C1 across CSS, SVG, and the HTA installer template; #06b6d4 survives only as a chart/highlight cyan.

Reasoning: spice-html5 was archived in 2023; the canonical browser display protocol in the libvirt + KVM stack is now noVNC-over-WS (KubeVirt pivoted in the same window). Maintaining SPICE as a fallback or premium tier was perpetual debt with no return — every 0.8.x debug entry below this one is SPICE plumbing keeping itself alive. WebRTC is reserved for post-1.0 evaluation as a possible successor to noVNC; it is not a SPICE-style coexistence path.

Operator impact: existing 0.8.x VMs whose XML still carries the SPICE shape continue to start under libvirt but the dashboard’s console button mints a VNC ticket. VMs without a <graphics type='vnc'> listener fail fast on console open with vncdisplay returned no display — refresh by re-defining the VM, or run the migrator’s video_migration pass which idempotently rewrites every domain in the libvirt store. Full upgrade flow: docs/UPGRADE_NOTES.md.


Target: ship the SYNVirt Universal Migrator end-to-end. Six adapters remained stubs through 0.12.x; this release lays the engine + the guided wizard frontend so the next 0.13.x point releases can flesh out per-adapter live-pull plumbing without churning the surface operators see.

synvirt-migrator · feature · MigrationPlan persistence + dry-run + readiness scoring

Section titled “synvirt-migrator · feature · MigrationPlan persistence + dry-run + readiness scoring”

Scope: crates/migrator/src/types.rs (PlanStatus, PlanRecord, RiskItem, RiskSeverity, RiskLevel, ReadinessItem, ReadinessScore, DryRunDiskEstimate, DryRunReport, BootMode, ChecklistItem / ChecklistRecord / ChecklistStatus — all together so types.rs stays the single source of truth); crates/migrator/src/state.rs (sled trees plans/ + checklists/); crates/migrator/src/jobs.rs (create_plan, list_plans, get_plan, delete_plan, dry_run_plan, start_plan, archive_plan, connect_nics, ensure_checklist, set_checklist_item, mark_validated, rollback_job, plus the dry-run scoring helper); crates/migrator/src/error.rs (PlanNotFound, PlanInvalidState, ReadinessBlocked + status-code mappings); crates/migrator/src/http.rs (eleven new handlers under /plans, /networks, /migrations/:id/connect-nics, /migrations/:id/checklist*, /migrations/:id/mark-validated, /migrations/:id/rollback); the new crates/migrator/src/compat/ tree (os_detector, driver_analyzer, firmware_analyzer, capacity_checker, readiness); the new crates/migrator/src/network/ tree (twin_builder, bridge_planner, conflict_detector); crates/migrator/src/orchestrator.rs (DomainStarter trait gains connect_nics + destroy_and_undefine defaults, runtime takes a &BootMode); crates/migrator/src/domain_starter.rs (link-down + bridge-swap rendering for protected boot, connect_nics + destroy_and_undefine virsh implementations); crates/migrator/src/sink.rs + zfs_sink.rs (snapshot + destroy_job defaults + ZFS implementations); crates/migrator/src/lib.rs re-exports.

Symptom Tony flagged: “el migrator hoy es un drawer único, sin plan persistido, sin dry-run, sin protected boot, sin rollback”. The dashboard’s quick-action drawer worked for the canary VIM-API path but had no notion of pre-migration validation, no readiness signal, no protected-boot policy, no checklist, and no destination cleanup. Operators flying multiple migrations in a maintenance window had no audit trail and no rollback button.

Root cause: The 0.12.x migrator was a one-shot orchestrator. POST /migrations would build the plan in memory, materialise it as a job, and start it in the same call. There was nowhere to inspect the plan, no readiness contract between the migrator and the rest of the daemon, no first-boot policy, and no recorded handoff between the migration and the operator’s validation. Adding any of those pieces meant disentangling the plan from the job.

Fix (six self-contained phases — A1 through A6 in the brief):

  • A1 — Plan persistence + dry-run. Sled tree plans/ keyed by plan id, wrapped by a PlanRecord with PlanStatus { Draft | Validated | Started | Archived }. New endpoints POST /plans, GET /plans, GET/DELETE /plans/:id, POST /plans/:id/dry-run, POST /plans/:id/start. Legacy POST /migrations is preserved as a single-shot atajo. The dry-run resolves the source manifest cache-first, builds a DryRunReport with per-disk estimates, optimistic / pessimistic transfer windows, downtime projection (cold ≈ transfer; warm bounded by delta_convergence + 30 s cutover overhead), structural risks (R_MIG_WARM_DOWNGRADE, R_MIG_LARGE_PAYLOAD, R_MIG_NO_DISKS), and an optional ReadinessScore. Auto-downgrade flips a live-warm plan to cold whenever manifest.cbt_available == false.

  • A2 — Compatibility module + ReadinessScore. New crates/migrator/src/compat/ with five files. os_detector flags an Unknown family as a warning so the operator knows the destination profile will fall back to a generic preset. driver_analyzer blocks Windows guests that need virtio injection when the host has no preparer (C_MIG_VIRTIO_INJECTION_MISSING). firmware_analyzer calls out OVMF and Secure-Boot prerequisites. capacity_checker shells through a PoolCapacityProvider trait (so unit tests stay offline) and gates the migration on a 10 % safety margin. readiness aggregates everything into a ReadinessScore { score: 0–100, risk: Low/Medium/High/Blocked, items[] }. Score is 100 − 8 · warns − 25 · blocks; any block short-circuits to Blocked. The dry-run injects the score into DryRunReport.readiness. start_plan refuses to advance when the dry-run found a blocker (E_MIG_READINESS_BLOCKED, HTTP 422).

  • A3 — Protected first boot. New BootMode { Normal | NicDisconnected | ValidationNetwork(bridge) } field on MigrationPlan (default Normal for compat). render_domain_xml now emits <link state='down'/> on every NIC for NicDisconnected and rebinds every NIC to a single isolated bridge for ValidationNetwork, leaving plan VLANs intact for the protected boot. The orchestrator takes an optional pre-boot zfs snapshot <dataset>@pre-boot per disk when plan.initial_snapshot == true (default). New POST /migrations/:id/connect-nics walks virsh domiflist and runs domif-setlink … up for every interface so the operator can flip the link back after validation. LibvirtDomainStarter also implements destroy_and_undefine (force-poweroff tolerant + --nvram aware) so A6 can rebuild the destination cleanly. Eight inline tests cover every render permutation including the SCSI-on-UEFI / IDE-on-q35 disk-bus coercions.

  • A4 — Network Twin builder. New crates/migrator/src/network/ with three files. twin_builder produces a NetworkTwin { strategy, mappings[], bridges_to_create[], conflicts[] } from a Vec<SourceNetwork> + HostNetworkInventory. bridge_planner synthesises one BridgePlan per VLAN-aware trunk (collapsing every source onto vmbr0) or one per VLAN id (PerVlanBridge). conflict_detector flags management-bridge overlap as Block, VLAN-not-in-allowlist as Warn, duplicate sources as Block, empty target bridge as Block. Manual mode honours operator overrides verbatim. New endpoints POST /networks/plan + POST /networks/validate.

  • A6 — Post-migration checklist + rollback. Sled tree checklists/ keyed by job id; the canonical 10-item checklist mirrors the spec’s “Post-migration validation” list. Endpoints GET /migrations/:id/checklist, POST /migrations/:id/checklist/items/:item_id (status + note), POST /migrations/:id/mark-validated, POST /migrations/:id/rollback. Rollback runs destroy + undefine on the destination domain via the new DomainStarter::destroy_and_undefine, then asks the sink to destroy_job (ZFS sink: zfs destroy -rf <pool>/synvirt/migrations/<job-id>), and flips the checklist to RolledBack. The source VM is never touched — the rollback is destination-only by contract.

Tests grew from 26 to 61 unit tests on the migrator crate. cargo fmt, cargo clippy --tests -- -D warnings, cargo test -p synvirt-migrator, and cargo build -p synvirt-daemon all pass.

Verification (post-deploy): smoke probe planned: curl /api/v1/migrator/plans should 200 with []; the wizard at /v2/migrator/wizard should render Step 1.

Deployed: 172.16.11.102 — daemon hot-swapped, see footer.

web-ux-v2 · feature · 9-step Universal Migrator wizard

Section titled “web-ux-v2 · feature · 9-step Universal Migrator wizard”

Scope: crates/web-ux-v2/src/views/migrator/MigratorWizardView.vue (host shell + footer + stepper); crates/web-ux-v2/src/components/migrator/wizard/ (WizardStepper.vue, Step1Source.vue through Step9FirstBoot.vue); crates/web-ux-v2/src/composables/useMigratorWizard.ts (REST wrapper for the eleven new endpoints); crates/web-ux-v2/src/stores/migrator-wizard.ts (Pinia store: operator’s draft + per-VM plan / dry-run / job / checklist refs); crates/web-ux-v2/src/types/migrator-wizard.ts (hand-typed mirrors of PlanStatus, DryRunReport, ReadinessScore, NetworkTwin, ChecklistRecord); crates/web-ux-v2/src/types/migrator.ts (MigrationPlan gains boot_mode + initial_snapshot); crates/web-ux-v2/src/components/migrator/NewMigrationDrawer.vue (legacy drawer adopts the new fields); crates/web-ux-v2/src/router/routes.ts (/migrator/wizard route).

Symptom Tony flagged: “el migrator hoy se ve como un drawer cualquiera del v2; necesitamos un wizard guiado de verdad”. The 0.12.x quick-action drawer was fine for one-shot live pulls but never matched the spec’s nine-step flow (Source → Inventory → VMs → Mode → Networks → Storage → Validation → Migration → First boot). Operators landing in the migrator surface had no idea what was about to happen, no preview of conflicts, and no validation gate before starting.

Root cause: v0.12.x’s NewMigrationDrawer was a single-pane form. There was no shell for the sequential-decision flow the spec calls out, no place for a per-VM mode picker, no place to surface the network twin or readiness score.

Fix: new /migrator/wizard mount, alongside the existing /migrator landing (the legacy drawer remains available as a quick-action). Each step is its own Vue component:

  • Step 1. Six adapter cards + offline file row. Lucide icons; the protocol slug is monospaced; selection drives a re-key of the dependent draft state.
  • Step 2. Compatible-source list (filtered by the chosen adapter family), counters strip (VMs / Running / Stopped / Fresh), and a hierarchical inventory tree. Datastore + Network nodes render placeholder copy until adapters surface those endpoints.
  • Step 3. Dense table with per-row checkbox + filter chips (All / Running / Stopped / Selected) + search input. Highlighting a row reveals a sticky inspector that lazy-loads the manifest from the migrator’s cache.
  • Step 4. Side-by-side Cold-vs-warm briefs (pros / cons / “best fit”) followed by a per-VM segmented Cold/Warm picker with a recommended hint based on payload size.
  • Step 5. Three strategy cards (VLAN-aware default, PerVlanBridge, Manual). Picking one calls the new /networks/plan endpoint and renders the proposed mapping table + the bridges-to-create list + an inline conflict feed (severity tones match the readiness scale).
  • Step 6. Pool picker with a capacity bar (required vs available, ok / tight / block tones), per-VM destination name input, and snapshot + auto-start toggles.
  • Step 7. Single “Run dry-run” CTA. The wizard creates one plan per selected VM via POST /plans, calls POST /plans/:id/dry-run for each, and renders an aggregate panel: transfer min-max, downtime min-max, blocker count, and per-plan readiness breakdown with each item’s severity icon.
  • Step 8. Mission-control view with a per-VM progress bar, throughput, cancel button, and 3 s polling loop until every job lands completed.
  • Step 9. One panel per migrated VM with Reconnect NICs / Mark validated / Rollback CTAs and the canonical 10-item checklist (per-item passed / failed / skipped buttons). The protected boot mode is read back from plan.boot_mode so the operator sees what shipped.

The Pinia store is the single source of truth: every step component reads / writes state.draft and calls store actions, never the REST surface directly. The store materialises a one-VM MigrationPlan per selected VM at Step 7 (because the v0.13 backend takes one VM per plan), keeps the resulting PlanRecord / DryRunReport / MigrationJob / ChecklistRecord indexed by source-VM id, and gates canAdvance on the most-restrictive condition for each step (no plan can have a blocker, every selected VM must have a mode, every NIC must have a target bridge, etc.).

Design choices (in lieu of running /impeccable shape interactively, applied inline against crates/web-ux-v2/src/styles/tokens.css): Restrained color strategy with brand #0566C1 accent ≤10 % of any surface; tinted neutrals, OKLCH-derived RGB triplets the existing tokens already encode; status tones from the existing --color-{ok,warn,err}-soft scales. No purple-pink gradients, no glassmorphism, no hero-metric template, no identical-card grids, no side-stripe borders, no gradient text. Type scale 11–26 px with 1.25× weight contrast; mono used only for protocol slugs / bridge names / byte counts. Motion stays under 300 ms with --easing-emphasis for the stepper progress fill and --easing-decel for hover transitions. Lucide icons inline; no icon fonts.

Verification: npm run typecheck clean; npm run lint clean on every new file (4 pre-existing errors in unrelated branches survive); 278 of 280 vitest specs pass (the 2 fails are pre-existing in iso-library/__tests__, unrelated to the wizard).

Deployed: 172.16.11.102dist/ hot-swapped, see footer.

Cargo.toml workspace version 0.12.8 → 0.13.0. iso-builder/VERSION 0.12.8 → 0.13.0. crates/web-ux-v2/package.json 0.12.6 → 0.13.0. The daemon’s RELEASE constant picks up the new value via crates/daemon/build.rs.


2026-04-23 — 0.8.2 production prep (in progress)

Section titled “2026-04-23 — 0.8.2 production prep (in progress)”

Target: single-host standalone release cut. Tony’s brief: “nada de fixes rápidos, salimos producción mañana” — every change below is production-grade, not a quick patch.

web-ux · P0 · Login stuck until F5 — successful auth never triggered a route resolve

Section titled “web-ux · P0 · Login stuck until F5 — successful auth never triggered a route resolve”

Scope: crates/web-ux/components/Login.js (post-login navigation).

Symptom Tony flagged: “entro las credenciales, no dice/hace nada, le doy F5 y ya me manda al dashboard principal”. The network request to /api/v1/whoami returned 200 (Basic Auth cached, session.user populated) but the view stayed on the login card. Pressing F5 reran the router’s initial resolve() which saw the existing session and redirected to #/.

Root cause: js/router.js::start() re-evaluates the route on two signals: the initial call and the hashchange event. After a successful session.login(...) the URL was still #/login; no hashchange fired, and the public-route guard if (isPublic && session.user) → redirect only runs inside resolve(). The original Login.js comment (“on success the router redirects away from #/login”) was aspirational — nothing was actually triggering that resolve. The bug has been latent since the hash router shipped, but nobody caught it in the canary flow because an auto-filled password manager stamps the form before the dashboard paints and the first resolve() does the redirect for them.

Fix: Login.js now forces the hash change itself after a successful login. It reads ?redirect= off the current hash (same contract the router’s guard uses when it bounces an unauthenticated request to the login page) and sets window.location.hash = redirect, defaulting to #/. If the redirect target happens to equal the current hash (rare — only when the operator landed on #/login with ?redirect=#/login), it flicks through an unreachable #/__force first so the hashchange listener fires.

Verification: headless-Chrome probe post-fix — navigate to #/login, fill root/1234, click submit, observe location.href flip to https://172.16.11.102/#/ with no errors and no manual refresh.

Deployed: 172.16.11.102 live — new Login.js scp’d to /opt/synvirt/web-ux/components/.

libvirt + daemon + web-ux · P1 · Real adapter + VRAM in place of “Modern graphics”

Section titled “libvirt + daemon + web-ux · P1 · Real adapter + VRAM in place of “Modern graphics””

Scope: crates/synvirt-libvirt/src/display.rs (new VideoModel + VideoProfile + parse_video_profile), crates/synvirt-libvirt/src/{vm,client,lib}.rs (wrapper + re-export), crates/daemon/src/api/vms.rs (VmView gains video_model + video_vram_mib; GET wires them alongside display_kind), crates/daemon/src/api/vm_hardware.rs (edit endpoint accepts vram_mib; new rewrite_video_vram rewrites the QXL trio ram/vram/vgamem or the VGA vgamem in place), crates/web-ux/components/VmDetail.js (new adapterSummary computed replaces displayLabel in the two info-rows + the header), crates/web-ux/components/VmEditHardware.js (VRAM input, visible for QXL/VGA), crates/web-ux/assets/strings/{es_mx,en_us}.json (10 new keys: vm.adapter.*, vm.hw.vram, vm.hw.vram_hint). Key parity preserved at 518 / 657 (the delta is pre-existing migrator-only keys absent from the Spanish catalog).

Symptom Tony flagged: the Summary card read Gráficos · Gráficos modernos after the previous fix — the label “Gráficos” and the value “Gráficos modernos” created the same visual redundancy the VmDetail translation pass was supposed to eliminate. Worse, “Gráficos modernos” was an abstraction over the graphics protocol (SPICE vs VNC), not the emulated device (QXL, virtio-gpu, Cirrus, VGA). An operator debugging a tearing guest, a driver-install failure, or a SPICE handshake needs the literal device model and its VRAM — not a category. There was also no way to change VRAM from the dashboard even though the QXL trio (ram=vram=128 MiB, vgamem=64 MiB) was hardcoded in xml::render.

Root cause: DisplayKind (spice/vnc_legacy/unknown) was the only video field the API exposed. No adapter model, no VRAM surface. EditHardwareBody only accepted video: Option<VideoModel>; VRAM rewriting never existed in any layer.

Fix:

  • Backend. New VideoModel enum (qxl | virtio | cirrus | vga | unknown) and VideoProfile { model, vram_mib: Option<u32> } in display, mirrored by vm::video_profile(name) + Client::vm_video_profile(). The VmView now carries video_model: Option<String> + video_vram_mib: Option<u32>; the single-VM GET piggybacks on a second virsh dumpxml --inactive (bulk listing is unchanged — still zero dumpxmls). EditHardwareBody.vram_mib: Option<u32> feeds rewrite_video_vram, which preserves the canonical QXL ratio (ram = vram = N*1024 KiB, vgamem = N/2*1024 KiB) and overwrites only vgamem for standard VGA. virtio-gpu and Cirrus intentionally return the XML unchanged — they have no operator-visible sizing knob. 5 new unit tests cover the rewriter (QXL update, QXL insert-from-scratch, virtio no-op, missing-block clean error, plus the pre-existing model-swap test).
  • Frontend. VmDetail’s two info-rows + the header all render the new adapterSummary computed: QXL · 128 MiB when VRAM is exposed, virtio-gpu alone when it isn’t. The Edit Hardware modal sprouts a VRAM number input (16–1024, step 16) that shows only when the selected adapter is QXL or VGA; the modal seeds video from vm.video_model (authoritative) with the legacy display_kind map kept as a fallback for unseeded VMs. Catalogs gain vm.adapter.{qxl,virtio,cirrus,vga,unknown} + vm.hw.vram + vm.hw.vram_hint in both locales.

Verification: live probe on 172.16.11.102:

  • GET /api/v1/vms/winxp-01"video_model": "qxl", "video_vram_mib": 128.
  • Temp VM tmp-vram created with POST /api/v1/vms → reports qxl / 128 MiB.
  • POST /api/v1/vms/tmp-vram/hardware {"vram_mib":256} → 200; next GET reports qxl / 256 MiB.
  • cargo test -p synvirt-daemon vm_hardware → 5/5; cargo test -p synvirt-libvirt display → 7/7.

Deployed: 172.16.11.102 live — daemon rebuilt + hot-swapped (winxp-01 kept running through the restart), VmDetail.js + VmEditHardware.js + both catalogs scp’d to /opt/synvirt/web-ux/.

web-ux · P0 · Chrome Translate killed every button click in the dashboard

Section titled “web-ux · P0 · Chrome Translate killed every button click in the dashboard”

Scope: crates/web-ux/index.html (<html> attributes + notranslate meta).

Symptom: Even after the Cache-Control fix below flushed Tony’s disk cache, “Crear VM” (and every other primary button in the dashboard) refused to do anything on click. DOM inspection showed the button rendered as <button class="btn primary" type="submit">Crear VM</button> — not disabled, correct text, correct class. Mouse events reached the element (pointerup fired) but Vue’s @submit.prevent and @click handlers never ran. A headless Chrome pointed at the same appliance submitted the form cleanly on the first click, so the appliance and the code were ruled out.

Root cause: index.html shipped with <html lang="en"> while js/strings.js hard-codes _locale = "es_mx" and the entire surface renders from assets/strings/es_mx.json. Chrome detects the language mismatch, offers “Traducir esta página”, and — if the operator ever accepted it or has auto-translate enabled — mutates the DOM by wrapping every text node in a <font style="vertical-align:inherit"> element. Vue 3’s template compiler stores event handlers against the original vnode children; when Chrome’s translator replaces those children in place, Vue’s delegated handler sees a click target it does not recognise and silently drops the event. The button looks and clicks like a normal button; no event ever reaches the Vue instance.

This is a well-documented Vue + Chrome Translate interaction. Standard mitigation for a single-tenant operator app: opt out of translation entirely. The dashboard is not a content page — every visible string is routed through Synvirt.t() and translated at render time, so browser translation can only cause harm.

Fix: index.html now declares <html lang="es" translate="no"> plus <meta name="google" content="notranslate"> in <head>. lang="es" removes the mismatch so Chrome stops offering to translate in the first place; translate="no" is the W3C standard attribute that freezes the DOM; the Google meta is belt-and-suspenders for older Chromium builds that ignore the attribute on the root element. When the catalog gains a runtime language switcher, the lang attribute will follow _locale — that change is deferred until the switcher lands.

Deployed: 172.16.11.102 live — new index.html scp’d. Operators need a single Ctrl+Shift+R (Chrome will paint “mostrar original” if it had an active translation); after that the translate banner never reappears and the dashboard clicks land in Vue.

daemon · P0 · Dashboard submit silently broken after scp hotfixes — no Cache-Control on static assets

Section titled “daemon · P0 · Dashboard submit silently broken after scp hotfixes — no Cache-Control on static assets”

Scope: crates/daemon/src/web/routes.rs (new static_cache_headers middleware wrapped around the outer Router).

Symptom: On 172.16.11.102 the create-VM form “did nothing” on submit. No POST reached the daemon (journalctl -u synvirt-daemon showed zero defining VM spans from the operator’s browser) while a clean headless Chrome pointed at the same dashboard created the VM end-to-end on the first click. Backend, PAM auth, libvirt, SPICE module — all healthy. curl -I showed every /js/*.js, /components/*.js, and /index.html response carried only Last-Modified; Chrome applied RFC 9111 heuristic freshness — (now - Last-Modified) / 10, i.e. hours — and kept serving stale JS out of disk cache.

Root cause: The dashboard ships with staged deploys (iso-builder/build.sh writes the appliance on a cold build, then individual scp hotfixes update specific files between ISO cuts). tower_http::services::ServeDir sets Last-Modified but does not set Cache-Control. With mtimes of CoreBars.js 15:22, DashboardHome.js 04:27, VmCreate.js 13:08, api.js 11:43 drifted over the day, the operator’s browser held a mix of fresh-and-stale JS. Chrome’s in-memory copy of the store module had a stale Pinia action signature; the new VmCreate.js called into it, a synchronous TypeError fired inside the click handler, and the form appeared to “do nothing” while no network request ever left the tab.

Fix: New static_cache_headers middleware, layered between the static tree and the trace-id outermost layer. API, metrics, probe, swagger-ui, and console endpoints are skipped. Everything else gets Cache-Control: no-cache so the browser keeps the bytes locally but always revalidates with If-Modified-Since — the daemon answers 304 Not Modified without sending a body, so the cost is a single TLS round-trip per file per page load and every deploy is immediately visible. The pinned /vendor/ tree (Inter, JetBrains Mono, Vue, Pinia, Tailwind, Lucide, xterm) keeps public, max-age=604800, immutable — those paths are part of a release, never touched by scp hotfixes, so long-caching them is free. Verified: /index.html / /js/api.js / /components/VmCreate.jscache-control: no-cache + last-modified; conditional GET with If-Modified-Since304; /vendor/inter/InterVariable.woff2cache-control: public, max-age=604800, immutable; /api/v1/health unchanged.

Operator note: existing browsers still hold pre-fix copies in disk cache until the heuristic window passes. A single Ctrl+Shift+R (or DevTools → Network → Disable cache) clears it; from that point forward the new headers keep the tab coherent with the appliance forever.

Deployed: 172.16.11.102 live — daemon rebuilt + hot-swapped, winxp-01 kept running through the restart (RuntimeDirectoryPreserve=yes). Post-swap curl probe confirms the new Cache-Control values and 304 revalidation on mutable paths.


2026-04-22 — 0.8.2 production prep (in progress)

Section titled “2026-04-22 — 0.8.2 production prep (in progress)”

Target: single-host standalone release cut. Tony’s brief: “nada de fixes rápidos, salimos producción mañana” — every change below is production-grade, not a quick patch.

iso-builder / web-ux · P1 · Fonts never landed in the 0.8.1 appliance

Section titled “iso-builder / web-ux · P1 · Fonts never landed in the 0.8.1 appliance”

Scope: iso-builder/build.sh, crates/web-ux/assets/fonts/* (obsolete files removed), CLAUDE.md §4.2.

Symptom: Fresh 0.8.1 installs returned 404 on /vendor/inter/InterVariable.woff2, /vendor/inter/InterVariable-Italic.woff2, and the four /vendor/jetbrains-mono/JetBrainsMono-{Regular,Medium,SemiBold,Bold}.woff2 files. Dashboard rendered with browser fallback fonts.

Root cause: iso-builder/build.sh copied only the sub-trees it explicitly listed (vue, vue-demi, pinia, tailwind, lucide, xterm, novnc) into the staging /opt/synvirt/web-ux/vendor/. The vendor/inter/ and vendor/jetbrains-mono/ directories, referenced directly by crates/web-ux/assets/css/theme.css:187-222, were checked into the repo but never staged.

A secondary inconsistency surfaced: CLAUDE.md §4.2 declared the product fonts as Geist + JetBrains Mono, but theme.css and the pinned vendor/inter/ directory used Inter. build.sh was fetching Geist woff2s from jsdelivr into assets/fonts/ and never using them.

Fix: Extended build.sh to install -d and cp -a both vendor/inter/ and vendor/jetbrains-mono/ into the staging tree. Removed the dead Geist + JB-Mono fontsource fetches and the five obsolete woff2 files under assets/fonts/. Aligned CLAUDE.md §4.2 with reality: Inter (variable) + JetBrains Mono, sourced from crates/web-ux/vendor/{inter,jetbrains-mono}/.

Deployed: 172.16.11.102 live — 6 woff2 files copied via scp into /opt/synvirt/web-ux/vendor/{inter,jetbrains-mono}/. All return 200 with correct byte sizes. Future ISOs produced by build.sh will stage them automatically.

iso-builder · P0 · QEMU SPICE module missing on 0.8.1 ISO

Section titled “iso-builder · P0 · QEMU SPICE module missing on 0.8.1 ISO”

Scope: iso-builder/live-build/config/package-lists/synvirt.list.chroot (line 35, already present), appliance 172.16.11.102 live install.

Symptom: POST /api/v1/vms returned 500 with {"error": "unsupported configuration: spice graphics are not supported with this QEMU (code=E_VIRSH_FAILED)"}. No VM could be defined because every domain XML declared <graphics type='spice'>.

Root cause: Debian 13 split SPICE support out of the base qemu-system-x86 package into a separate optional module, qemu-system-modules-spice (which transitively pulls libspice-server1). Without it, libvirt’s domcapabilities omits <value>spice</value> from the graphics enum and every attempt to define a SPICE domain is rejected at virsh define time.

The package list at synvirt.list.chroot:35 already had qemu-system-modules-spice with a comment explaining the split — but the 0.8.1 ISO running on 172.16.11.102 was built before that line was added. The fix was already in source; the running appliance just predated it.

Fix: Installed qemu-system-modules-spice live on 172.16.11.102, restarted libvirtd, cleaned the orphaned winxp-01.qcow2 from a half-completed earlier attempt. qemu-system-x86_64 -spice help now reports SPICE options and a subsequent POST /api/v1/vms returned 201 Created.

No source changes needed — future ISOs already include the package. The 0.8.1 appliance is patched in place.

Deployed: 172.16.11.102.

web-ux · P1 · Console surface dropped “SPICE” from primary UI; UUID moved to Advanced disclosure

Section titled “web-ux · P1 · Console surface dropped “SPICE” from primary UI; UUID moved to Advanced disclosure”

Scope: crates/web-ux/components/VmDetail.js, crates/web-ux/assets/strings/en_us.json, crates/web-ux/assets/strings/es_mx.json.

Symptom: The Summary tab showed a raw UUID row and a button labeled “Consola SPICE”; displayLabel rendered the protocol token "SPICE" / "VNC" directly in the status row. Hardcoded Spanish strings (“Iniciar”, “Apagar”, “Reiniciar”, “Forzar apagado”, “Cambiar ISO”, “Eliminar”, “Información”, “Consola”, “Pantalla”, “UUID”, “Política USB”, “Resumen”, “Hardware”) lived inline in the template and bypassed the Synvirt.t() catalog.

Root cause: VmDetail.js was last touched during 0.8.0 and its template strings were never migrated. The displayLabel computed returned the raw protocol identifier, which §4.2 forbids (“Raw terms allowed only in collapsed ‘Advanced’ sections and tooltips”). The secondary console button was drafted as “Consola SPICE” when the pop-out feature was first added.

Fix:

  • displayLabel maps the raw kind to vm.display_kind.{spice,vnc,unknown} labels (“Modern graphics” / “Legacy graphics” / “Unknown”); the raw token is still available on the value-cell title attribute so technicians can confirm the protocol from a tooltip.
  • Secondary console button renamed to vm.console.popout (“Open in separate window”) with a plain-English tooltip.
  • UUID row removed from the Summary tab and tucked inside a disclosed “Advanced details” section at the bottom of the Hardware tab (hidden by default, toggled by vm.advanced.toggle).
  • All action labels, headers, info labels, and tab titles now resolve through Synvirt.t(). Added 30 new keys to both en_us.json and es_mx.json covering vm.nav.back, vm.tab.*, vm.section.*, vm.info.*, vm.console.*, vm.usb_policy.*, vm.display_kind.*, vm.advanced.toggle, and vm.change_iso.* additions. Both catalogs stay synchronised.
  • Normaliser alignment: normaliseState now returns running|paused|crashed|shutoff, directly matching vm.state.* keys. The short badge label was dropped in favour of the full state label — it reads cleaner and already fit the existing badge width.

Deployed: Source only. Awaits the ISO rebuild / daemon restart that bundles the updated web-ux tree.

libvirt + daemon + web-ux · P1 · QXL as the default video adapter + Edit Hardware modal

Section titled “libvirt + daemon + web-ux · P1 · QXL as the default video adapter + Edit Hardware modal”

Scope: crates/synvirt-libvirt/src/xml.rs (video_model collapsed to a single return); crates/daemon/src/api/vm_hardware.rs (new POST /api/v1/vms/:name/hardware); crates/daemon/src/web/routes.rs + openapi.rs; crates/web-ux/components/VmEditHardware.js (new); crates/web-ux/components/VmDetail.js + js/api.js + CSS + string catalogs.

Why QXL across the board. SynVirt already ships SPICE as its display protocol (<graphics type='spice'> mounted on a per-VM Unix socket). QXL is the paired display device that protocol was built for — every supported guest in the product’s matrix has a working driver: virtio-win QXL-DoD on Windows (XP → 11), the in-tree qxl kernel module on Linux 2.6+, ports drm-kmod on BSD. The previous per-profile split (virtio-gpu modern / QXL transition / cirrus XP / VGA legacy-linux) traded consistency for a mild 3D edge on a subset of guests. Collapsing to QXL means one stable stack for the operator and one predictable SPICE compression story for the dashboard. Operators who want paravirtual 3D on a Linux guest can still pick virtio-gpu from the new Edit Hardware modal on a per-VM basis; the default just no longer forces it.

Edit Hardware endpoint. New POST /api/v1/vms/:name/hardware takes an all-optional JSON body {vcpus?, ram_mib?, video?, vram_mib?}. Applies each change through virsh setvcpus / setmaxmem / setmem --config for the scalar knobs, and rewrites <video><model type='…' vram='…'> in the persistent XML via virsh dumpxml --inactive | rewrite | virsh define /dev/stdin. Hard requirement: the VM must be shut off; a running domain returns 409 E_VM_BAD_STATE before any command fires. VideoModel is a closed enum (Qxl / Virtio / Cirrus / Vga) so the XML cannot be poisoned with an arbitrary -vga token. Two unit tests cover the video-model rewriter happy path and the “missing <video> block” failure mode.

UI. A new Editar hardware button sits on the VmDetail action row, disabled while the VM is running with a tooltip explaining the rule. Click opens VmEditHardware.js — vCPUs / RAM MiB / Graphics adapter dropdown / VRAM (conditional on QXL & VGA). The form seeds from the current VM on open so edits land on known-good values; on submit it calls the endpoint once, toasts success/failure, and refreshes the Summary card so the new values appear immediately.

Windows XP migration, in place. winxp-01 was defined with Cirrus pre-upgrade. virsh destroy winxp-01 && POST /hardware {"video":"qxl","vram_mib":128} rewrote the XML to <model type='qxl' ram='131072' vram='131072' vgamem='65536' heads='1' primary='yes'/>. VM started back up on QXL with 128 MiB VRAM; SPICE thumbnail and in-browser console both stay live across the swap.

Deployed: 172.16.11.102 live. Daemon binary hot-swapped (other running VMs on the host — winxp-02, srv2022-01 — kept running through the restart thanks to RuntimeDirectoryPreserve=yes). Web-ux deltas scp’d. End-to-end verified: edit-hardware 200, live XML dump confirms QXL + VRAM figures, VM powered back on with HTTP 200.

libvirt + daemon + web-ux · P1 · Memory card honest about guest-agent; CPU card legible with 1–16 cores

Section titled “libvirt + daemon + web-ux · P1 · Memory card honest about guest-agent; CPU card legible with 1–16 cores”

Scope: crates/synvirt-libvirt/src/stats.rs + crates/daemon/src/api/vm_stats.rs (guest-agent-gated fields), new crates/web-ux/components/CoreBars.js, VmDetail.js + CSS + catalogs.

Symptoms Tony flagged after reviewing the previous cut:

  1. Memory card showed “105 % — Host 2.09 GiB · Guest 2.00 GiB” for a Windows XP guest whose own Task Manager reported ~320 MiB in use. The “Guest 2.00 GiB” claim was a lie — balloon.current is the post-balloon allocation, not real in-guest usage.
  2. The per-core ribbon rendered as two 6-px lines of sparkline on a 2-vCPU VM. Un-scannable. Tony could not tell which core was active without hovering.

Root cause:

  1. Without qemu-guest-agent installed inside the VM, libvirt has no visibility into how much RAM the guest OS is actually using. We were treating balloon.current as a working-set proxy; it is not. On a guest with no balloon driver it is pinned to the configured maximum forever.
  2. The ribbon design was optimised for 32–128 cores. At 1–8 cores — the common VM shape in this product — each row was tiny and the big number above it stole all the attention.

Fix — memory, honestly:

  • stats.rs now parses balloon.unused (populated only by qemu-guest-agent) and derives mem_guest_used_kib = balloon.current − balloon.unused. The struct exposes mem_guest_used_kib: Option<u64>, mem_guest_unused_kib: Option<u64>, and mem_guest_pct: Option<f64>None when the agent is absent.
  • The dashboard card’s big number is now mem_rss_kib in GiB (2.09 GiB / 2.00 GiB host) — always truthful because QEMU’s RSS is always available on a Linux host. Underneath, the bar tracks mem_rss_pct. The detail line below renders either Guest 320 MiB (16% of 2 GiB) when the agent is active, or "Install guest-tools for in-guest memory usage" when it is not. No more pretending to know.
  • Legacy field names mem_used_kib and mem_pct retired from the API surface; no external consumers existed yet.

Fix — CPU, legible:

  • New <syn-core-bars> component: one horizontal row per vCPU, 52 px label + flexible track + 44 px number. Track is a 14-px chunky bar with cyan→blue gradient on the fill; fill switches to warn orange at 80 % and red at 95 %. Smooth 220 ms width transition on every poll tick.
  • VmDetail now chooses: ≤ 16 vCPUs → syn-core-bars (readable); > 16 → the old ribbon stays, compacted. At 2 vCPUs the operator sees vCPU 0 [████████░] 3 % and vCPU 1 [███░░░░░░] 2 % — clear at a glance which core is busy.

Verification — live probe against winxp-01:

cpu_pct: 2.69 %
cpu_per_core: [2.94, 0.98]
mem_rss_kib: 2_193_280 # host-side truth
mem_guest_used_kib: null # agent absent → honest null
mem_guest_pct: null
mem_rss_pct: 104.6 %

Playwright screenshot confirms the two cards reading as intended.

Deployed: 172.16.11.102 live — daemon binary swapped, winxp-01 kept running. Web-ux deltas scp’d (new component registered in index.html, CSS extensions, +4 catalog keys at 496/496 parity).

libvirt + daemon + web-ux · P1 · CPU card becomes a per-core ribbon; memory card tells the three-number balloon story

Section titled “libvirt + daemon + web-ux · P1 · CPU card becomes a per-core ribbon; memory card tells the three-number balloon story”

Scope: crates/synvirt-libvirt/src/stats.rs (per-vCPU parsing + balloon.rss), crates/daemon/src/api/vm_stats.rs (schema), new crates/web-ux/components/{CoreRibbon,MemoryBar}.js, crates/web-ux/components/VmDetail.js (template + ring buffer), crates/web-ux/index.html, CSS + string catalogs.

Problem statement. The previous metrics card hid every interesting CPU story behind a single aggregate percentage: two cores, one pinned at 100 % and the other idle, showed as “50 %” — the pattern recognition problem the operator most cares about (which core is hot?) was invisible. The memory card showed balloon.current / balloon.maximum * 100, which collapses to a flat 100 % on every guest that doesn’t have a balloon driver (i.e. almost all of them on launch day) and never mentions the figure the hypervisor actually pays for — balloon.rss.

CPU fix — Core Ribbon. libvirt’s virsh domstats --raw already exposes vcpu.<i>.time in nanoseconds for every vCPU. stats.rs::sample now walks 0..vcpu.current and computes the delta on each axis; the result ships as cpu_per_core: Vec<f64> alongside the aggregate. The dashboard feeds that into a 30-sample-per-core ring buffer and renders <syn-core-ribbon> — one horizontal mini-sparkline per vCPU, stacked vertically, with a 3 px “now” tick at the right edge coloured against a four-stop ramp (cyan → blue → warning orange → danger red). Degradation logic: above 32 cores the grid splits into two side-by-side columns so rows never drop below 3 px; above 128 cores the same code path just keeps tightening. One hot core among cold neighbours now reads as a single bright streak in a dim wash — the 5-second question (“is this VM CPU-starved, one hot core, or all idle?”) is answered at a glance.

Memory fix — Nested Bar. balloon.rss joins the rate struct as mem_rss_kib with a derived mem_rss_pct. The new <syn-memory-bar> component draws three aligned primitives on one track: the outer rectangle is balloon.maximum (the ceiling), the inner cyan→blue fill is balloon.current (what the guest sees), and a half-height top-aligned overlay is balloon.rss (what the host actually holds). When the balloon driver is absent current == max; the guest bar fills the track and the rss overlay carries the whole story. When the balloon inflates, the guest bar retracts visibly while the host bar moves independently. When rss > current — guest claims it freed memory but QEMU still holds the pages — the rss overlay switches from its normal deepblue gradient to a warning-orange → red gradient, turning the discrepancy into the visual alarm. The card’s big number now reads mem_rss_pct instead of mem_pct, so an operator looking at “105 %” sees the real story (host overshoot) instead of the misleading “100 %” that every balloon-less guest used to show.

Verification. Live probe against winxp-01 (XP, no balloon driver):

  • cpu_per_core: [2.94, 0.0] — vCPU 0 just woken, vCPU 1 idle.
  • mem_used_kib: 2_097_152 (guest sees 2 GiB), mem_rss_kib: 2_193_152, mem_total_kib: 2_097_152, mem_rss_pct: 104.6 — a small real-world overshoot that the dashboard paints in warning orange. Playwright screenshot confirms the card: stacked core sparklines with per-core now-ticks, and the memory bar’s host overlay protruding in warning tones past the guest fill.

Deployed: 172.16.11.102 live — daemon rebuilt + hot-swapped (winxp-01 kept running through the restart), web-ux deltas scp’d (new components registered in index.html, CSS extensions for the ribbon/bar footprint, 2 new catalog keys vm.metrics.mem_{host,guest} at 494/494 parity).

web-ux + guest-tools-iso · P1 · Preview thumbnail survives transient 5xx; HTA installer routes XP/2003 to manual driver install

Section titled “web-ux + guest-tools-iso · P1 · Preview thumbnail survives transient 5xx; HTA installer routes XP/2003 to manual driver install”

Scope: crates/web-ux/components/VmDetail.js (blob-backed preview fetch); crates/guest-tools-iso/src/install_hta.tmpl (NT 5.x detection + manual-install panel). ISO rebuilt and re-mounted on 172.16.11.102.

Symptoms:

  1. After a guest reboot, the VM-detail console thumbnail rendered as a broken-image placeholder. Tony reported it did not recover until the next full page reload.
  2. The HTA installer was driving msiexec /i virtio-win-gt-x86.msi /quiet /norestart on Windows XP guests. The MSI refuses to run on NT 5.x (“This product cannot be installed on this version of Windows”) and the installer UI left the operator with two red error rows and no next step.

Root cause:

  1. The previous preview path used <img :src="previewSrc"> with a URL that bumped every 4 s. During the reboot window libvirt briefly serves 500s (QMP screendump channel not yet ready on a freshly-starting guest, or virsh complaining that the VM is in paused/shutting-down). The <img> element treats any non-2xx response as a load failure and paints the broken-image icon; it holds that state until the next successful src mutation, but because the tick counter is monotonic the same URL path keeps producing the same broken result for several cycles.
  2. The HTA bet on a single MSI driver install flow. XP and Server 2003 are NT 5.1 / 5.2 — the virtio-win installer MSIs (NSIS 2015-era wrappers) bail out in their launch-condition check against the OS version, and recent spice-guest-tools-latest.exe builds have also dropped 5.x. There is no silent install story left for those guests; the drivers have to go in via Device Manager’s Add Hardware wizard against the xp\x86 subfolder of the extracted virtio-win tree.

Fix:

Blob-backed preview. The dashboard now watches the previewUrl computed and, on every change, fetch()es the PNG with the session’s Basic-auth header, turns the response into a URL.createObjectURL(blob), and swaps the <img src> to that blob URL. Failures are silent: any non-OK response (or network blip) leaves the previous blob URL in place, so the operator sees the last good frame instead of a broken-image glyph. A single in-flight guard (_previewInflight) prevents the 4 s tick from piling up while a slow response is in flight, and beforeUnmount revokes the current blob to keep memory bounded. Template now ternaries between blob-available / waiting-first-frame / shutoff states.

XP / Server 2003 branch in the HTA. On boot the installer reads HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\CurrentVersion. Values 5.1 and 5.2 flip IS_LEGACY = true. When the operator clicks Install on a legacy guest, the JS skips the MSI drivers entirely: the three-step status list is stamped as “not supported on XP / 2003”, a new advisory panel unhides with a cyan call-to-action Open driver folder, and the log pane prints the manual path — <disc>\windows\virtio-win\xp\x86. The button launches explorer.exe /e,<path> so the operator can navigate straight to the INF files without first opening My Computer.

Deployed: Daemon unchanged (no backend work needed for either issue). guest-tools.iso rebuilt (1.2 GB, 1472 files), scp’d to 172.16.11.102:/opt/synvirt/isos/guest-tools.iso, and re-mounted on winxp-01 via POST /api/v1/vms/winxp-01/install-tools → 200. Web-ux VmDetail scp’d. Playwright screenshot confirms the thumbnail now renders post-reboot; metrics card simultaneously reports live disk writes (3.9 KB/s · 1 IOPS write) from the XP guest.

daemon + libvirt + web-ux · P1 · VmDetail live dashboard — framebuffer preview, metrics card, single primary console button

Section titled “daemon + libvirt + web-ux · P1 · VmDetail live dashboard — framebuffer preview, metrics card, single primary console button”

Scope:

  • crates/synvirt-libvirt/src/stats.rs (new rate-sampler) + lib re-exports.
  • crates/daemon/src/api/screenshot.rs (new, streams PNG).
  • crates/daemon/src/api/vm_stats.rs (new, 1 s sample window).
  • crates/daemon/src/web/routes.rs + openapi.rs wiring.
  • crates/web-ux/components/VmDetail.js — removed inline SPICE modal mount, removed redundant “Abrir en ventana aparte” button; primary Abrir consola now dispatches the pop-out directly; new metrics card; live framebuffer thumbnail.
  • crates/web-ux/js/api.js — added vmStats().
  • crates/web-ux/assets/css/theme.css — metrics grid + preview thumbnail.
  • 15 new catalog keys across vm.metrics.* and vm.console.preview_tooltip; catalogs parity at 492 / 492.

Symptoms (three operator-reported issues, shipped together):

  1. Two buttons on the console card (“Abrir consola” modal + “Abrir en ventana aparte” pop-out) doing effectively the same thing, with the modal the only one actually broken for live guests.
  2. Summary tab had no live preview of the guest screen — operators had to open the full console just to see whether Windows had finished booting.
  3. Summary had no real-time CPU / memory / disk / network telemetry — every number on the detail page was static config (vCPU count, configured RAM, disk path), nothing the operator could use to judge whether the VM was actually working.

Fix:

Button deduplication. Removed spiceOpen state, the <synvirt-console-modal :standalone="false"> mount on VmDetail, and the now-empty modal button. The remaining Abrir consola button invokes openPopout() (unchanged behaviour — opens /public/console.html?vm=<uuid>&name=<name> in a dedicated 1280×800 window); the serial-tab variant was routed through the same handler for consistency.

Live framebuffer preview. New GET /api/v1/vms/:name/screenshot (Rust handler at daemon/src/api/screenshot.rs). Invokes virsh screenshot <name> /run/synvirt/screenshots/<vm>-<uuid>.png, reads the result back, detects PNG vs PPM via magic bytes, streams the raw bytes with Content-Type: image/png and Cache-Control: no-store. Temporary files land on tmpfs and are unlink’d before the response finishes. Pre-check rejects shutoff VMs with a clean 409 E_VM_BAD_STATE so the dashboard shows the “VM is stopped” placeholder instead of bleeding a raw virsh error. VmDetail’s Summary console card renders a 16:10 thumbnail (.vm-console-preview) that refreshes every 4 s via a client-side previewTick counter appended as ?t= to bust the HTTP cache. Click on the preview opens the pop-out.

Live metrics. New synvirt_libvirt::stats::sample() takes two virsh domstats <vm> --raw snapshots 1 s apart, parses every numeric key into a flat HashMap<String, u64>, and computes: CPU percentage (delta(cpu.time) / wall_ns / vcpus * 100, clamped 0–100); memory (balloon.current / balloon.maximum in KiB + derived percentage); network bits/second (sum of net.<i>.rx/tx.bytes deltas × 8); disk bytes/second and IOPS (sum of block.<i>.rd/wr.bytes and .reqs deltas). Exposed as GET /api/v1/vms/:name/stats returning VmStatsView; shutoff VMs short-circuit to an all-zero body so the dashboard doesn’t pay the 1 s sample cost. New “Métricas en vivo” card above Información in the Summary tab renders four KPIs (CPU / Memory / Network / Disk) with 30-sample client-side ring buffers feeding syn-sparkline. Poll cadence is 3 s; _statsInflight guards against overlapping in-flight requests.

Verification: Smoke-tested against the appliance: GET /api/v1/vms/winxp-01/screenshot → 200 image/png 23 kB, 1024×768; GET /api/v1/vms/winxp-01/stats{cpu_pct: 1.52, mem_pct: 100.0, net_rx_bps: 0, net_tx_bps: 0, disk_rd_bps: 0, disk_wr_bps: 0, sample_ms: 1020}. Playwright screenshot shows the full Summary with metrics card, live preview, four info rows, three action buttons.

Deployed: 172.16.11.102 live — daemon rebuilt + hot-swapped, winxp-01 survived the restart (RuntimeDirectoryPreserve). CSS + JS deltas scp’d.

web-ux · P0 · Canvas height capped at 768px — spice-html5 writes inline style on the screen root, not the canvas

Section titled “web-ux · P0 · Canvas height capped at 768px — spice-html5 writes inline style on the screen root, not the canvas”

Scope: crates/web-ux/assets/css/theme.css (.console-canvas-host rules).

Symptom: Even after the previous !important pass on .console-canvas-host > canvas, Tony’s browser still showed the XP framebuffer stretched horizontally but not vertically. A Playwright getComputedStyle + offsetWidth/Height probe against the live appliance nailed it: the <canvas> was correctly 1600×907 from the CSS point of view, but its parent — .console-canvas-host — was only 1600×768 tall. The canvas could not exceed its ancestor.

Root cause: spice-html5 uses the screen-root element as its “display container” and writes element.style.width = '<guestPxW>px'; element.style.height = '<guestPxH>px' on every display resize. That root is the same <div class="console-canvas-host"> we declare in ConsoleModal.js. Inline style outranks a plain class selector, so the previous position: absolute; inset: 0; on the host was silently overridden back to the guest’s native 1024×768 on every frame.

Fix: Pin the host’s sizing axes with !important so inline writes lose. position: absolute !important; top/right/bottom/left: 0 !important; width/height: auto !important; overflow: hidden. The canvas inside keeps its earlier width/height: 100% !important; object-fit: fill rules; together, both stop collapsing back to 768 px.

Verification: Playwright probe now reports host: 1600×907, canvas: 1600×907 — exactly matching the wrap. Screenshot saved to /tmp/canvas-inspect.png shows the XP desktop stretching edge to edge on every side of the modal.

Deployed: 172.16.11.102 live — CSS scp’d, no daemon restart required.

guest-tools-iso + web-ux · P0 · Branded Windows installer, flat virtio-win tree, and true 4-corner console canvas

Section titled “guest-tools-iso + web-ux · P0 · Branded Windows installer, flat virtio-win tree, and true 4-corner console canvas”

Scope: crates/guest-tools-iso/src/{lib.rs,templates.rs,main.rs}, new crates/guest-tools-iso/src/install_hta.tmpl, crates/web-ux/assets/css/theme.css. Appliance ops: new 1.2 GB guest-tools.iso uploaded to 172.16.11.102:/opt/synvirt/isos/guest-tools.iso, winxp-01 re-mounted.

Symptom: Tony’s review of the previous cut landed three independent failures:

  1. “Me pusiste el ISO dentro del ISO” — the previous stage_payload copied the upstream virtio-win-0.1.240.iso verbatim into windows/ on the wrapper disc. XP and Server 2003 cannot mount an ISO from inside Windows without third-party tools (Daemon Tools / Virtual CloneDrive), so the drivers were effectively unreachable on the exact guests that need them most.
  2. “La pantalla no se expande hasta abarcar las 4 esquinas” — after the previous CSS change the canvas filled horizontally but not vertically. spice-html5 writes width and height attributes on its <canvas> every frame, and those attribute values outvoted the plain CSS width/height: 100%. Result: the canvas stretched on one axis only.
  3. “Haz super estético el instalador de tools para Windows con nuestro logo” — the Windows entry point was a bare install.bat with an ASCII banner. No brand presence, no visual progress, no logo.

Fix:

Extract instead of nest. stage_payload now invokes xorriso -osirrox on:auto_chmod_on -indev <virtio.iso> -extract / <staging>/windows/virtio-win/ and drops the loose tree under that path. The HTA and install.bat both pick the right MSI from the extracted tree (virtio-win-gt-x64.msi or -x86.msi) depending on the guest bitness. The final disc is 1.2 GB / 1472 files — slightly larger than the nested-ISO shape (0.6 GB) because we’re paying the ISO-9660 overhead once per file instead of once for the whole nested image, but XP and 2003 can now read the drivers with a plain Windows Explorer click. Tests were reworked to build a tiny real ISO via xorriso -as mkisofs for the extraction path to operate on; test runners without xorriso simply log skipping and exit cleanly.

Branded HTA installer. New src/install_hta.tmpl is a self-contained HTML Application — <hta:application> + inline CSS + JScript — rendered by the in-box mshta.exe on every supported Windows from XP through 11. The layout matches CLAUDE.md §4.2 styling: cyan→blue linear-gradient accent, SynVirt chevron lockup as the mark, three-step install list with live state dots (idle → active → done / error), monochrome JetBrains-style log pane, single primary CTA. The JScript side drives two silent installs via WScript.Shell.Run: spice-guest-tools /S, then the virtio-win MSI with msiexec /quiet /norestart, treating 3010 (reboot-required) as success. Autorun points at mshta.exe install.hta; install.bat stays as the command-line fallback and itself calls mshta when available.

Four-corner canvas, for real. .console-canvas-host > canvas now carries position: absolute !important; top/left: 0 !important; width/height: 100% !important; object-fit: fill!important outranks the inline width/height attributes spice-html5 writes, and position: absolute lets the canvas track the host rect on both axes. Verified against the live appliance via Playwright: the XP Explorer window fills the modal edge-to-edge, CONNECTED badge active. When vdagent comes up via the Install-tools flow above, the stretch becomes distortion-free automatically.

Deployed: 172.16.11.102 live — 1.2 GB ISO scp’d and chowned, winxp-01 re-mounted via POST /api/v1/vms/winxp-01/install-tools → 200; domain XML confirms <source file='/opt/synvirt/isos/guest-tools.iso' index='6'/> on hdc. Full-bleed canvas verified on the live dashboard.

daemon + web-ux + ops · P1 · One-click guest-tools install + 4-corner console canvas

Section titled “daemon + web-ux + ops · P1 · One-click guest-tools install + 4-corner console canvas”

Scope: crates/daemon/src/api/vms.rs (new install_tools handler), crates/daemon/src/web/routes.rs, crates/daemon/src/api/openapi.rs, crates/web-ux/js/api.js, crates/web-ux/components/VmDetail.js, crates/web-ux/assets/strings/{en_us,es_mx}.json, crates/web-ux/assets/css/theme.css. Appliance ops: guest-tools.iso scp’d to 172.16.11.102:/opt/synvirt/isos/guest-tools.iso.

Symptom / gap:

  1. The VM detail page’s SPICE canvas sat letterboxed with max-width:100% max-height:100%: XP booted at its native 800×600 and was rendered pixel-for-pixel in the middle of the modal, leaving wide black margins. Tony asked for the canvas to stretch edge-to-edge.
  2. guest-tools.iso (virtio drivers for Windows, vdagent for Linux) is already built by crates/guest-tools-iso, but the 0.8.1 appliance never staged it to /opt/synvirt/isos/ and the dashboard had no one-click way to mount it for the operator.

Fix (both items, shipped together):

Canvas stretch. .console-canvas-host > canvas switched from max-width/height: 100% to width: 100%; height: 100%; object-fit: contain; display: block. The host element stays position: absolute; inset: 0; so the canvas now fills the wrap on one axis and preserves guest aspect ratio on the other. Four-corner coverage on a widescreen modal becomes a free side-effect once the guest vdagent reports the matching viewport — which is exactly what the new “Install tools” button bootstraps.

Install-tools flow. New daemon endpoint POST /api/v1/vms/:name/install-tools (basic-auth, instrumented) calls Client::change_vm_media with the canonical path /opt/synvirt/isos/guest-tools.iso hard-coded server-side — keeping the dashboard from hard-coding a filesystem path means we can relocate the bundle in a future release without a web-ux redeploy. The handler 500s cleanly with E_GUEST_TOOLS_MISSING when the ISO is absent from the appliance (new error code, translated in both catalogs). VmDetail.js gained an installTools() method and a disabled-when-shutoff "Install tools" button on the Summary → Console card, next to the existing “USB policy” button. Five new keys in each catalog (vm.install_tools.{label,tooltip,done} + error.E_GUEST_TOOLS_MISSING.{title,action}), catalogs at 477 / 477 parity.

ISO on the appliance. guest-tools.iso (609 MB, volume label SYNVIRT_GT, SHA-256 6878ff0b…0767b0ed) uploaded to 172.16.11.102:/opt/synvirt/isos/guest-tools.iso, chown libvirt-qemu:libvirt-qemu, chmod 644. Endpoint verified end-to-end: curl -X POST …/install-tools → 200; virsh dumpxml winxp-01 | grep cdrom shows <source file='/opt/synvirt/isos/guest-tools.iso' …/> on hdc.

Deployed: 172.16.11.102 live. Screenshot at /tmp/vm-summary.png shows the Summary tab with the four-button console card; /tmp/console-ready.png shows the canvas now filling the modal’s vertical axis (horizontal letterboxing remains until the guest vdagent installs and auto-resizes).

daemon + console-gateway + libvirt · P1 · Audit sweep — panic-source hardening across the Rust workspace

Section titled “daemon + console-gateway + libvirt · P1 · Audit sweep — panic-source hardening across the Rust workspace”

Scope: crates/console-gateway/src/ticket.rs (4 sites), crates/daemon/src/api/install.rs (7 sites + 1 partition unwrap), crates/daemon/src/api/{disks,nics,vm}.rs (anyhow → ApiError), crates/synvirt-libvirt/src/{nic,vm}.rs (chars().next() unwraps).

Symptom / risk: Pre-audit, any panic inside a tokio task that held the TicketStore RwLock would leave the lock poisoned and turn every subsequent ticket request into expect("keys poisoned") — cascading panics across the entire process for the rest of its uptime. The InstallState Mutex had the same shape: its 5-minute async pipeline held the lock across .unwrap() sites, so a mid-install panic would wedge the install API permanently. install.rs also had a precondition-safe p4.as_deref().unwrap() on the single-disk ZFS partition path that would crash the installer if the detection logic ever shifted. A handful of library functions (nic::is_valid_bridge_name, vm::validate_name) called .unwrap() on “always-Some” iterator heads; technically safe today, but library code is supposed to surface Error::Invalid instead. Finally, several HTTP handlers still returned anyhow::Result, which hides structured error codes from the HTTP layer.

Fix:

  • ticket.rs — 4 .expect("keys poisoned") sites on keys.read() / keys.write() replaced with match ... Err(_) => return Err(ConsoleError::Internal(...)). The rotation background task now warn!-logs and continues on poison instead of aborting. ConsoleError::Internal(String) existed already — mapped to HTTP 500 without change.
  • install.rs — all 7 .lock().unwrap() calls on InstallState now go through .lock().map_err(|e| ApiError::internal("E_INSTALL_STATE", e.to_string()))?. The progress! / fail! macros, which run inside the spawned install task (no HTTP error path), switched to tracing::error! + early-return. The p4.as_deref().unwrap() partition path became a match that calls fail!("E_INSTALL_PARTITION") with a structured message.
  • disks.rs::scan_disks(), nics.rs::scan_nics(), legacy api::vm::list_vms() — signatures converted from anyhow::Result to Result<T, ApiError> with E_DISK_SCAN / E_NIC_SCAN codes. The private install.rs::sh() helper stays on anyhow::Result with a block comment (the errors are stringified into InstallProgress.error for the wizard; keeping anyhow here is a deliberate carve-out).
  • synvirt-libvirt/src/nic.rs::is_valid_bridge_namechars().next().unwrap() replaced with a match that returns false on the impossible empty path.
  • synvirt-libvirt/src/vm.rs::validate_namechars.next().unwrap() replaced with .ok_or_else(|| Error::Invalid { reason: "VM name must not be empty".into() })?.
  • snapshot.rs uses unwrap_or(' ') — already safe, untouched. Test-code unwraps in usb_policy.rs and xml.rs are #[cfg(test)] and exempt per §4.1.

Verification: cargo test -p synvirt-console-gateway --lib 12/12; cargo test -p synvirt-libvirt --lib 29/29; cargo clippy --workspace --no-deps zero errors, no new warnings introduced; cargo build --release -p synvirt-daemon → 24.9 MB binary.

Deployed: 172.16.11.102 live via binary swap. winxp-01 kept running through the restart thanks to RuntimeDirectoryPreserve=yes; post-restart GET /api/v1/vms still shows the VM in running and GET /api/v1/host/usage reports cpu=3.6 %, mem 2.9/16 GiB — matches reality.

console-gateway · P0 · Triple SPICE bug: missing binary subprotocol + single-shot ticket + /run/ wiped on daemon restart

Section titled “console-gateway · P0 · Triple SPICE bug: missing binary subprotocol + single-shot ticket + /run/ wiped on daemon restart”

Scope: crates/console-gateway/src/router.rs (subprotocol advertisement), crates/console-gateway/src/ticket.rs (consume semantics + test), crates/console-gateway/src/router.rs tests, iso-builder/live-build/config/includes.chroot/etc/systemd/system/synvirt-daemon.service (RuntimeDirectoryPreserve=yes).

Symptom: Graphical console never reached “connected”. Browser Developer Tools cycled through three distinct error shapes depending on which layer was currently broken:

  1. Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received (Chrome aborts before 101).
  2. WARN failed to dial SPICE socket … No such file or directory in the daemon log.
  3. HTTP Authentication failed; no valid credentials available on the 2nd / 3rd WebSocket of a SPICE session.

Root causes (three, independent):

  1. WebSocket subprotocol echo. The vendored spice-html5 opens each channel with new WebSocket(url, "binary"), which emits Sec-WebSocket-Protocol: binary on the upgrade. RFC 6455 requires the server to pick one of the offered protocols and echo it. axum’s WebSocketUpgrade defaults to advertising none; silent drop is fine for chatty upgraders but Chrome hard-aborts.
  2. Single-shot ticket consumed the nonce on first validation. SPICE opens 5–7 WebSockets per session (main / display / inputs / cursor / playback / record / cursor2). With DashMap::remove in validate_and_consume, the second upgrade saw a blank nonce and 401’d. The prior “single-use” JWT story was defensible in the abstract but incompatible with how spice-html5 actually drives channels.
  3. RuntimeDirectory=synvirt/spice got wiped on every systemctl stop. systemd’s default RuntimeDirectoryPreserve=no removes the tmpfs mount on service stop. VMs that were already running kept a file descriptor on the old socket, but the path — the only handle the gateway uses — ceased to exist. A fresh daemon binary swap was the trigger; live operators never saw this on long-running appliances.

Fix (all three, shipped together):

  1. Router advertises ws.protocols(["binary"]) on the upgrade; the RFC-correct echo now reaches Chrome.
  2. validate_and_consume switched from DashMap::remove to DashMap::get. Replay window is still bounded at ~30 s by the JWT exp; the 60 s sweep task reaps expired nonces as before. Updated the existing second_consume_fails unit test (now ticket_is_reusable_within_ttl) and the router’s end_to_end_* test to drive five and six successive validations.
  3. Unit file gains RuntimeDirectoryPreserve=yes with a comment explaining why. The tmpfs directory still evaporates on full reboot, which is the intended behaviour — we only need it to outlive routine daemon restarts while VMs keep holding sockets by path.

Deployed: Daemon rebuilt and hot-swapped on 172.16.11.102. Systemd reloaded, winxp-01 destroyed + started to pick up the new socket path. End-to-end verified with Playwright: login → VM detail → Open console → SPICE handshake 42 B → 202 B → 132 B → 4 B → 38 B → … → recv 16155 (framebuffer). Screenshot at /tmp/console-ready.png shows the Windows XP installer rendered inside the dashboard modal with a CONNECTED badge.

caddy-proxy · P0 · Browser → Caddy also needed HTTP/1.1; h2 listener turned off globally

Section titled “caddy-proxy · P0 · Browser → Caddy also needed HTTP/1.1; h2 listener turned off globally”

Scope: caddy-proxy/Caddyfile global block → servers { protocols h1 }.

Symptom: After pinning Caddy → daemon to HTTP/1.1, the browser still failed to open wss://10.61.26.5/console/ws with “Connection refused / Connection timed out”. DevTools showed the WebSocket request carried HTTP/2 pseudo-headers (:authority, :method: GET, :path, :scheme), meaning Chrome negotiated HTTP/2 against Caddy via ALPN.

Root cause: Chrome on HTTP/2 issues WebSocket upgrades as RFC 8441 Extended CONNECT with :protocol: websocket. axum on the daemon side only handles the classic HTTP/1.1 Connection: Upgrade handshake, and Caddy does not transparently rewrite an h2 Extended CONNECT into an h1 upgrade against the upstream — it refuses the handshake.

Fix: Disable HTTP/2 on the listener so the browser falls back to HTTP/1.1 end-to-end:

servers {
protocols h1
}

Verified via openssl s_client -alpn h2,http/1.1 — Caddy now advertises only http/1.1 in ALPN. alt-svc: h3=":443" is gone. Consequence: slightly more TCP handshakes per page load (HTTP/2 multiplexing is gone), which is fine for a single-host admin surface accessed by one to three operators at a time. A dedicated WebSocket subdomain that stays HTTP/1.1 while the main surface keeps HTTP/2 would be the cleaner long-term shape; deferred past launch.

Deployed: caddy reload on the dev VM. No restart, no socket re-bind.

web-ux · P0 · HostOverview mount crashed the dashboard — duplicate methods: object shadowed refreshHostUsage

Section titled “web-ux · P0 · HostOverview mount crashed the dashboard — duplicate methods: object shadowed refreshHostUsage”

Scope: crates/web-ux/components/HostOverview.js.

Symptom: Every page load raised TypeError: this.refreshHostUsage is not a function at Proxy.mounted (HostOverview.js:55). The store restore path died during bootstrap, leaving the dashboard half-initialised — modal sub-trees like the SPICE console wouldn’t attach cleanly and operator interaction degraded across the surface.

Root cause: The round-2 bilingual cleanup agent explicitly mentioned leaving “the pre-existing duplicate methods: block in HostOverview.js” alone. Vue merges a single component definition object; when the same key (methods:) appears twice in the options literal, the second instance wins. My earlier host-usage wiring put refreshHostUsage in a methods: block sitting above computed:. A later edit added a second methods: block below computed: that held only setFilter. JS object construction kept the later block, throwing away refreshHostUsage. mounted() still called it → TypeError.

Fix: Deleted the earlier orphan methods: block, moved refreshHostUsage into the single surviving methods: next to setFilter. One methods: + one computed: now, which a future grep can enforce.

Deployed: 172.16.11.102 live — web-ux delta scp’d.

caddy-proxy · P0 · SPICE WebSockets failed through the Caddy reverse proxy (10.61.26.5)

Section titled “caddy-proxy · P0 · SPICE WebSockets failed through the Caddy reverse proxy (10.61.26.5)”

Scope: caddy-proxy/Caddyfile.

Symptom: Browser console on wss://10.61.26.5/console/ws?ticket=… reported Connection refused and Connection timed out, even though /api/console/<uuid> (the ticket issue call) succeeded through the same proxy and dashboard HTTP traffic worked fine. The daemon log showed no bridge activity for these attempts — the upgrade was failing at the Caddy boundary.

Root cause: Caddy talks to the upstream daemon over TLS and Caddy 2 negotiates HTTP/2 via ALPN by default. axum’s WebSocketUpgrade only implements the classic HTTP/1.1 Connection: Upgrade handshake, not WebSockets over HTTP/2 (RFC 8441). Result: regular HTTP requests proxied cleanly via HTTP/2; Connection: Upgrade was dropped/rewritten on the HTTP/2 leg and never completed the 101 Switching Protocols exchange. The browser saw an aborted socket.

Fix: Pin the Caddy → daemon transport to HTTP/1.1 only, and disable upstream response buffering so SPICE frames pass through unbatched.

reverse_proxy https://172.16.11.102 {
transport http {
tls_insecure_skip_verify
versions 1.1
}
flush_interval -1
}

Verified with a raw HTTP/1.1 upgrade through the proxy: HTTP/1.1 101 Switching Protocols · Server: Caddy · Upgrade: websocket.

Deployed: caddy reload --config Caddyfile --adapter caddyfile on the dev VM. No service restart needed.

libvirt · P0 · SPICE handshake rejected by spice-html5 — domain XML used server-auto codec + streaming filter

Section titled “libvirt · P0 · SPICE handshake rejected by spice-html5 — domain XML used server-auto codec + streaming filter”

Scope: crates/synvirt-libvirt/src/xml.rs lines 327-328. Live redefinition of winxp-01 on 172.16.11.102 via POST /api/v1/vms/:name/redefine-xml.

Symptom: Operator (Tony) reported “URGENTE Common causes: the VM isn’t running, SPICE isn’t enabled in the domain XML, or the daemon can’t reach its Unix socket.” — the failure overlay ConsoleModal.js renders when SpiceSession.onStateChange('error', ...) fires. The VM was running, the gateway returned HTTP 101 on /console/ws?ticket=…, the SPICE Unix socket existed at /run/synvirt/spice/<uuid>.sock, and getting a ticket via POST /api/console/<uuid> returned a valid JWT with sub=<uuid>. Everything looked green; spice-html5 just refused the session with a bare-Event error.

Root cause: The XML renderer emitted <image compression='auto_glz'/> and <streaming mode='filter'/> on every graphics block. Both settings are valid for native clients (remote-viewer / virt-manager), but the vendored spice-html5 only understands the explicit compression modes (glz | lz | quic | off) and doesn’t negotiate streaming mode='filter'. When the SPICE server starts offering the auto-picked compression or filter streaming, spice-html5 closes the channel; the opaque DOM Event reaches SpiceSession._normalizeError, which stringifies it to “unknown error” — feeding the “Common causes” overlay.

The daemon logs showed no error because the gateway side of the bridge (axum WebSocket → Unix socket pump) worked fine. The handshake failure happened inside the browser’s SPICE layer.

Fix: Pin conservative values that spice-html5 does support:

<image compression='glz'/>
<streaming mode='off'/>

Added a block comment in xml.rs documenting why these values are what they are, so the next person who tries to “optimise” the SPICE codecs reads the trade-off first. Trade-off cost: slightly larger on-wire frames on a LAN we already fully control — acceptable for a single-host product.

Deployed: Daemon release binary rebuilt (cargo build --release -p synvirt-daemon, 59 s) and swapped on 172.16.11.102 (stop / mv / start). winxp-01 destroyed, POST /api/v1/vms/winxp-01/redefine-xml → 200 (upgraded: true, prev=spice, new=spice), VM started. virsh dumpxml winxp-01 | grep graphics confirms compression='glz' + streaming mode='off'. Operator notified to Ctrl+F5 and retry.

web-ux · P1 · Bilingual cleanup round 2 — HostOverview, UsbPolicyDrawer, Storage/ISO/Settings/Network/Login

Section titled “web-ux · P1 · Bilingual cleanup round 2 — HostOverview, UsbPolicyDrawer, Storage/ISO/Settings/Network/Login”

Scope: crates/web-ux/components/{HostOverview,UsbPolicyDrawer,StorageView,IsoLibrary,SettingsView,NetworkView,Login}.js; 98 new keys in both catalogs.

Symptom (§1 / §4.2 violation): After round 1 purged hardcoded Spanish from VmList/VmCreate/AppRail/AppTopbar, the operator-facing surface still had ~50 Spanish literals across the rest of the components: Host overview KPI subtitles (“residente N%”, “libres”), USB class labels (audio/hid/printer/etc.), mode radios (allow_all/deny_all/class_filtered), Storage health badges (degradado/fallo/desconocido), ISO upload text, Settings appearance section, Network kind tags, Login placeholders.

Fix: Delegated to an Explore agent under tight rules (EN default, no VMware/ESXi product names, key parity preserved, no behavioural changes). All seven files migrated; 98 new keys added under home.kpi.*, home.trend.*, home.filter.*, home.rack.*, home.welcome.*, vm.usb_policy.*, storage.*, iso.*, settings.*, network.*. UsbPolicyDrawer replaced its hardcoded CLASSES array with a CLASS_IDS string list + a classes computed that resolves labels via t().

Catalog sizes: en_us.json 472 / es_mx.json 472 — parity verified. Residual-Spanish grep across the seven files is empty.

Deployed: 172.16.11.102 live. Both catalogs staged, all seven component files scp’d.

web-ux · P1 · Bilingual cleanup of VM list + VM create + shell chrome; column sort on VM list

Section titled “web-ux · P1 · Bilingual cleanup of VM list + VM create + shell chrome; column sort on VM list”

Scope: crates/web-ux/components/VmList.js, VmCreate.js, AppRail.js, AppTopbar.js; 40 new keys in both en_us.json and es_mx.json (both catalogs now at 374 keys, parity verified).

Symptom: The VM surface and the shell chrome (left rail, top bar, breadcrumbs, search) mixed hardcoded Spanish into Vue templates. Examples: <h1>Máquinas virtuales</h1>, <input placeholder="Filtrar por nombre…" />, segmented control labels Todas | On | Off | Error, OS-profile tile titles ("Linux moderno", "Windows transición"), submit buttons Cancelar / Aplicar / Arrancar / Crear VM. An English-native product showing Spanish everywhere the operator lives is the single largest §1 + §4.2 violation on the launch demo.

Root cause: When these views were authored (pre-0.8.0) the string catalog did not yet cover every surface, so authors inlined Spanish literals. The catalog caught up later but the templates were never migrated.

Fix: Every user-visible string in the four files now routes through Synvirt.t(key). New keys added under nav.* (home, alerts), app.topbar.* (crumbs, search placeholder, logout tooltip, create-VM button), vm.list.* (subtitle count, search placeholder, state filter labels), vm.state.* (stopped/error aliases to existing shutoff/crashed), and the full vm.create.* spread covering OS tiles, memory / disk / vCPU sliders, recommendation hints, and submit states.

Column sort on VmList (the “VIP list ergonomics” piece of the brief): clicking Name, State, vCPUs, or RAM in the header toggles asc↔desc. Sort state lives in data() as sortBy / sortDir and applies inside the existing filtered computed — store schema untouched. Active column shows a / glyph and the <th> carries a cursor-pointer style. Row click still opens the VM detail; header clicks stay in <thead> so they never bubble into a row open.

Deployed: 172.16.11.102 live. Residual Spanish audit across the four files is empty (grep for Máquinas|Nombre|Estado|Crear|Cancelar|Aplicar|Disco|Memoria|Red returns zero matches outside comments).

web-ux · P0 · “Open console” button was hitting the VNC-only endpoint — graphical console never connected on 0.8+ VMs

Section titled “web-ux · P0 · “Open console” button was hitting the VNC-only endpoint — graphical console never connected on 0.8+ VMs”

Scope: crates/web-ux/components/VmDetail.js (primary button target, tab rename, watcher normalisation, openPopout helper), string catalogs (vm.tab.serial, vm.console.popout_title).

Symptom: Tony reported “sigue sin conectarse a la consola” on 172.16.11.102. Daemon log showed WARN could not resolve VNC port name=winxp-01 on every operator click. The in-page graphical console never mounted.

Root cause: VmDetail.js had a “Console” tab with a Graphical | Serial switcher. Graphical called connectVnc()new WebSocket(/api/v1/vms/:name/console/vnc)api::console::vnc handler → resolve_vnc_port(&name). That handler only works for pre-0.8.0 VMs that declared <graphics type='vnc' port='...'> in their domain XML. Every 0.8+ VM (including winxp-01) is defined with <graphics type='spice' listen='socket'> per the 0.8.0 console module — SPICE sockets, no VNC port. The daemon correctly returned “no port”, the UI sat on “connecting…” forever.

The SPICE path had always existed: a pop-out button that opened <synvirt-console-modal> via /console/ws?ticket=…. It worked in isolation, but the primary “Open console” button sent operators to the broken VNC path.

Fix: Route every graphical-console entry through the SPICE modal.

  • Open console (primary button in Summary → Console card and on the Serial console tab) now sets spiceOpen = true → renders <synvirt-console-modal :standalone="false">, which mints a short-lived ticket and streams SPICE over /console/ws.
  • Open in separate window opens /public/console.html?vm=<uuid>&name=<name> with window.open(...) in a 1280×800 window, named synvirt-console-<uuid> so two clicks don’t spawn two copies. The bootstrap page already knew how to run <synvirt-console-modal> standalone against the same gateway.
  • The tab formerly called “Console” is now “Serial console”. It mounts only the xterm-based serial WebSocket bridge (/api/v1/vms/:name/console/serial), which was already correct. Removed the obsolete Graphical | Serial sub-switcher and the unused vncHost ref. Internal tab value renamed console → serial; watchers and teardown paths updated accordingly.

VNC-port legacy path stays wired in the daemon (api::console::resolve_vnc_port) for any pre-0.8.0 VM that hasn’t been regenerated via /vms/:name/redefine-xml, but nothing in the current UI reaches it. If a future hidden-in-Advanced toggle wants it back, the endpoint is still there.

Deployed: 172.16.11.102 live — web-ux delta scp’d, no daemon restart needed (presentation-only change). Ctrl+F5 picks up the new VmDetail.js.

web-ux · P1 · Home screen — honest status banner + Recent activity panel

Section titled “web-ux · P1 · Home screen — honest status banner + Recent activity panel”

Scope: crates/web-ux/components/DashboardHome.js, new crates/web-ux/components/RecentActivity.js, crates/web-ux/js/events.js (fanout broadened), index.html script order, string catalogs (17 new keys across home.health.*, home.activity.*, event.*).

Context: §4.2 5-second rule requires the home screen to answer “is everything OK?” at a glance. Before this change the home screen showed three neutral KPI cards (VM count, pool count, network count) with no rolled-up status; a degraded pool only surfaced if you clicked into Storage.

Fix — Health banner: New healthAggregate computed walks the stores once per render.

  • err if any pool is failed|unavail|faulted or any VM is crashed.
  • warn if any pool is degraded, any VM is paused|suspended, or the daemon has been up <60 s (fresh restart).
  • ok otherwise. Banner lives above the KPI grid with a coloured left-rule (ok/warn/err) and a bullet list of active issues, each a translated home.health.issue.* line. Poll uses the existing 30 s store refresh plus a new GET /api/v1/health piggyback for uptime_seconds.

Fix — Recent activity: js/events.js now fans every DaemonEvent (known + unknown kinds) out to window.dispatchEvent("synvirt:event", {detail: {kind, data, received_at}}). New RecentActivity.js component subscribes on mount, keeps the last 20 entries in memory, and renders each one as a humanised line from the event.<kind> key. A 1 s tick data property keeps Intl.RelativeTimeFormat honest (“just now → 5s ago → 2m ago”) without re-querying the server. Mounted inside DashboardHome below the KPI grid (only when not in first-boot mode).

Deployed: 172.16.11.102 live.

web-ux · P1 · Central error-toast translator; raw stderr purged from the UI

Section titled “web-ux · P1 · Central error-toast translator; raw stderr purged from the UI”

Scope: new crates/web-ux/js/errors.js; index.html script order; 11 call sites migrated across VmCreate.js, VmCard.js, VmList.js, VmDetail.js, VmSnapshots.js, StorageView.js.

Symptom: Catch-site toast calls were this.toasts.push("error", e.message || this.t("error.generic.title")). When the daemon bubbled up a VirshFailed { stderr } (e.g. the SPICE graphics rejection, or libvirt refusing a revert), e.message contained the raw subprocess stderr — “virsh virsh define (stdin) failed (exit 1): error: unsupported configuration: spice graphics are not supported with this QEMU (code=E_VIRSH_FAILED)”. That wire format is useful in the daemon log, not in a toast. §4.2 forbids it.

Root cause: No layer between api.js’s thrown error envelope (which already carries a stable code) and the toast store. Each component reached into e.message directly.

Fix: New helper Synvirt.errorMessage(err) in js/errors.js:

  • Uses err.code to look up error.<code>.title and error.<code>.action in the string catalog.
  • Falls back to generic title + preserved err.message context when the code is unknown (new daemon variants don’t regress the UI).
  • Handles kind: "network" and kind: "unauthorized" distinctly.
  • Appends an 8-char trace_id slice in parentheses so operators can grep the daemon log without ever seeing raw stderr.

Synvirt.pushErrorToast(toasts, err) wraps the common pattern. A python3 re.sub batch swapped every this.toasts.push("error", e.message || this.t("error.…")) site for Synvirt.pushErrorToast(this.toasts, e). Calls that already pushed specific translated strings (e.g. storage.create.failed, iso.upload.failed) were left alone — they’re context-specific and already compliant.

libvirt / daemon / web-ux · P1 · VM snapshots — internal qcow2 checkpoints, shipped end-to-end

Section titled “libvirt / daemon / web-ux · P1 · VM snapshots — internal qcow2 checkpoints, shipped end-to-end”

Scope: new crates/synvirt-libvirt/src/snapshot.rs, extended error.rs + lib.rs + client.rs; new crates/daemon/src/api/snapshots.rs, routes mounted in web/routes.rs, OpenAPI + error mapping, three new DaemonEvent variants; new crates/web-ux/components/VmSnapshots.js, wired into VmDetail.js as a fourth tab; 30+ new keys in both string catalogs.

Symptom / gap: Operators had no way to roll a VM back to a known-good checkpoint from the dashboard. The only recourse was opening a shell on the host and calling virsh snapshot-create-as by hand — not a “VIP single-host” story, and directly blocking the common “take a snapshot before the Windows update” workflow.

Fix: Internal qcow2 snapshots via virsh snapshot-create-as --atomic. Chosen over external backing-file snapshots because they keep the VM’s disk layout as one file per domain, match the existing crates/synvirt-libvirt/src/disk.rs model, and need no additional allocation step. Four operations exposed end-to-end:

  • GET /api/v1/vms/:name/snapshots — newest first, returns name/parent/captured-at/state-at-capture/is-current.
  • POST /api/v1/vms/:name/snapshots — body {name, description?}. 90 s timeout (not the default 30 s) because memory capture on a multi-GB guest crosses the default budget. Name is validated client-and-server-side against the same charset as VM names.
  • POST /api/v1/vms/:name/snapshots/:snap/revert — restores disk + RAM; libvirt implicitly stops a running guest first.
  • DELETE /api/v1/vms/:name/snapshots/:snap — refuses when the snapshot has children (409 E_SNAPSHOT_HAS_CHILDREN) so the operator can delete leaves first; tree-aware deletion is intentionally out of scope for 0.8.2.

New Error variants — SnapshotNotFound, SnapshotAlreadyExists, SnapshotHasChildren — each with a stable E_SNAPSHOT_* code, mapped to 404/409/409 in daemon/api/error.rs::From<LibvirtError>. Error messages from virsh ("already exists", "has descendants", "no domain snapshot with matching name") are pattern-matched and translated before reaching the HTTP layer so the dashboard sees structured codes instead of raw stderr. Unit tests in snapshot.rs cover both the name validator and the stderr classifier.

UI: new “Snapshots” tab in VmDetail.js renders <synvirt-vm-snapshots :vm-name="…">. Table shows Name, Captured (relative time with absolute tooltip, using Intl.RelativeTimeFormat), State, Parent, + a “Current” badge on the active snapshot. Inline “Take snapshot” form validates the name prefix client-side. Revert + Delete route through <synvirt-confirm-modal :require-typed-name> — the operator must type the snapshot name, matching the VM-delete discipline. Three new DaemonEvent variants (VmSnapshotCreated, VmSnapshotReverted, VmSnapshotDeleted) fan out over the existing SSE stream so other open dashboard tabs refresh. Revert also emits VmStateChanged { state: "reverted" } as a hint to trigger a VmDetail poll, since libvirt’s implicit stop isn’t otherwise visible until the next poll.

Deployed: 172.16.11.102 live — daemon binary swapped (1 s downtime), web-ux tree scp’d. Smoke test against the appliance:

  • GET /api/v1/host/usage{cpu_pct: 9.89, mem_pct: 4.21, load_1m: 0.08} (matches vmstat, no more 58 %/32 % demo data).
  • POST /api/v1/vms/winxp-01/snapshots → 201 with name, parent, created_at, state_at_capture, is_current.
  • GET …/snapshots → list with correct parent chain (smoke-2’s parent=pre-test, both is_current transitioning correctly).
  • DELETE …/snapshots/{smoke-2,pre-test} → 204 each.

Timestamp parse fix: Initial deploy returned created_at: "" because virsh snapshot-info on libvirt 11 (Debian 13) dropped the Creation Time: line. Switched fetch_info to parse <creationTime>UNIX</creationTime> from virsh snapshot-dumpxml instead. More robust: uses actual XML + chrono RFC-3339 formatting, survives further libvirt text-output churn. Unit test (extracts_xml_tags) covers the happy and no-parent paths.

daemon / web-ux · P1 · Host CPU / RAM are now honest numbers

Section titled “daemon / web-ux · P1 · Host CPU / RAM are now honest numbers”

Scope: new crates/daemon/src/api/host_usage.rs (sampler + GET /api/v1/host/usage); state extension in api/health.rs::Inner; sampler spawn in main.rs; route + OpenAPI registration; crates/web-ux/js/api.js (hostUsage()); rewritten polling loop in crates/web-ux/components/HostOverview.js.

Symptom: Dashboard home showed CPU 58 % and RAM 32 % on an idle appliance (operator-reported via 172.16.11.102). A fresh vmstat on the same host reported idle=75 %, guest=25 % and free -h showed 900 MiB used of 15 GiB (≈ 5.7 %). The dashboard disagreed with reality.

Root cause: HostOverview.js had never been wired to a real host-metrics source. Its cpuSeries and ramSeries were seeded with a pseudo-random Brownian walk (demoSeries(40, 55, 12) and demoSeries(40, 60, 8)) that drifted around 55 % and 60 %. The comment on the file acknowledged the placeholder: “CPU / memory / network trends are pseudorandom demo series until a proper host-metrics API is wired.” That API was never added.

Fix: New endpoint GET /api/v1/host/usage returns a HostUsage envelope with cpu_pct, load_1m/5m/15m, mem_used_bytes, mem_total_bytes, mem_pct, and sampled_at. A background tokio::task::spawn refreshes the sysinfo System every 2 s via spawn_blocking (sysinfo’s /proc walk is short but synchronous; we keep it off the async reactor). The first sample primes CPU counters by sleeping MINIMUM_CPU_UPDATE_INTERVAL — without the prime, the first CPU sample is always 0 %. Handler reads the latest snapshot under a shared RwLock, so request latency is flat regardless of sampler work.

HostOverview.js drops demoSeries / nextSample and tracks a real 40-sample ring buffer that the component appends to on every /api/v1/host/usage response (2.5 s cadence). On first response it seeds the buffer with the current value so the sparkline draws a line rather than a baseline. RAM card now shows used / total GiB when the endpoint has reported at least once, falling back to the percentage until then. The cpuTrend / ramTrend labels switched from the misleading “1h” suffix to “2m” — it was always the sparkline-window delta, never an hour.

Deployed: Source only. cargo check -p synvirt-daemon green. Will take effect on the next daemon binary deploy to the appliance.

daemon · P2 · Local libvirt access no longer routes through polkit (partial)

Section titled “daemon · P2 · Local libvirt access no longer routes through polkit (partial)”

Scope: iso-builder/live-build/config/includes.chroot/etc/systemd/system/synvirt-daemon.service, new iso-builder/live-build/config/hooks/normal/0120-libvirt-local-auth.hook.chroot, live edit of /etc/libvirt/libvirtd.conf on 172.16.11.102.

Symptom: journald at ~1.6 lines/second of polkitd: Registered Authentication Agent for unix-process:NNNN (pkttyagent) / Unregistered …, paired, on every dashboard poll. Under load (VmDetail open + VmList refreshing + Prometheus scrape) the pairs drowned real events.

Root cause: The daemon invokes virsh as a subprocess for every libvirt query (virsh.rs::run). Each virsh invocation connects to qemu:///system, whose default Debian policy is auth_unix_rw = "polkit". libvirt’s client library then preemptively spawns pkttyagent to be ready if polkit prompts — even when polkit will trivially grant the call, and even when the caller has no controlling tty. The daemon does ~1 query every 2 s per open VmDetail tab; each query pays the pkttyagent round-trip.

Fix applied:

  1. Systemd unit: SupplementaryGroups=libvirt libvirt-qemu — places the daemon in the libvirt group without running as a non-root user.
  2. libvirtd.conf overrides via idempotent hook (0120-libvirt-local-auth.hook.chroot): unix_sock_group = "libvirt", unix_sock_rw_perms = "0770", auth_unix_rw = "none", auth_unix_ro = "none". Access is now gated purely by group membership.

Known limitation: virsh still registers/unregisters pkttyagent on every invocation because libvirt-client spawns the agent before checking whether it’ll be needed. The D-Bus traffic continues at the same rate; polkit just never does a real auth check any more. The journald lines remain until the daemon is refactored to use the libvirt native API (virt crate) instead of virsh subprocesses — tracked as a 0.8.3 follow-up.

Deployed: 172.16.11.102 live — unit reloaded, libvirtd.conf patched, libvirtd + synvirt-daemon restarted. Group membership verified via /proc/$PID/status (Groups: 106 64055 → libvirt + libvirt-qemu). Future ISOs will stage both changes automatically.


### `area` · P0|P1|P2 · One-line title
**Scope:** file(s):line(s).
**Symptom:** what the operator saw.
**Root cause:** the one sentence that survives a grep.
**Fix:** what changed. If a design doc or CLAUDE.md clause drove the choice, cite it.
**Deployed:** host or "source only".