Skip to content

SynVirt Web UI (crates/web-ux)

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

Document version: 0.8.0 · Audience: contributors reading or modifying the post-install dashboard.

The dashboard is a Vue 3 single-page application served as static files by synvirt-daemon. It has no build step — every file in the crate is shipped verbatim, vendored JS/CSS lives under /vendor/, and the browser loads the page straight off disk. This lets the appliance run fully air-gapped: the network is never consulted at runtime.

Every mention of “the daemon” in this document refers to synvirt-daemon.service listening on port 443 of the appliance.


crates/web-ux/
├── index.html # entry point, mounts the Vue app
├── lib.rs # thin crate stub for workspace membership
├── assets/
│ ├── css/theme.css # CSS variables + component styles
│ ├── fonts/*.woff2 # Geist + JetBrains Mono, self-hosted
│ └── strings/en_us.json # user-visible strings, keyed by id
├── js/
│ ├── strings.js # Synvirt.t(key, vars) — i18n façade
│ ├── api.js # single fetch() client + auth header
│ ├── store.js # Pinia stores (session, vms, storage, ...)
│ ├── router.js # hash-based router + auth guard
│ ├── events.js # SSE consumer → store dispatch
│ └── app.js # Vue mount + component registration
├── components/ # dashboard components (one per file)
│ ├── TopNav.js # persistent top chrome + nav links
│ ├── Login.js # PAM/Basic-Auth login form
│ ├── DashboardHome.js # 4-card "is everything OK?" screen
│ ├── VmList.js · VmCard.js · VmDetail.js · VmCreate.js
│ ├── StorageView.js · NetworkView.js · IsoLibrary.js
│ ├── SettingsView.js · CommandPalette.js · ThemeToggle.js
│ ├── (removed in 0.9.0-alpha.2: UsbPolicyDrawer.js — SPICE-shaped)
│ ├── Badge.js · Icon.js · Spinner.js · Toast.js
│ └── EmptyState.js · ConfirmModal.js
├── public/ # VNC console sub-tree (0.9.0-alpha.1+)
│ ├── console.html # standalone pop-out window
│ ├── lib/console-api.js # auth + ticket helpers
│ ├── lib/console-store.js # shared Pinia factory (modal + pop-out)
│ ├── lib/vnc-session.js # noVNC wrapper for the WS bridge
│ ├── lib/resize-observer.js # framebuffer ↔ viewport rescaler
│ └── components/ConsoleModal.js · ConsoleToolbar.js · ConsoleStatusBar.js
└── vendor/ # Vue, Pinia, Tailwind, Lucide, xterm, noVNC

Everything under crates/web-ux/ is staged by iso-builder/build.sh into /opt/synvirt/web-ux/ on the appliance, and the daemon’s static-file handler serves the tree under / (with /api reserved for the control plane).


index.html
├── inline <script> sets data-theme before CSS loads
│ (prevents a white flash on dark-preferred browsers)
├── <link> theme.css · xterm.css
├── <script type="importmap"> ← only resolves "@novnc/novnc/core/rfb.js"
├── classic <script>s in dependency order:
│ vendor/* (vue, pinia, xterm)
│ js/strings.js · api.js · store.js · router.js · events.js
│ public/lib/*.js (console-api, console-store, resize-observer)
│ public/components/*.js
│ components/*.js (each pushes itself onto Synvirt.components)
│ js/app.js ← creates the Pinia instance, mounts #app
└── <div id="app"></div>
  • Air-gap: every asset is already on disk. A Webpack bundle would still land on disk, but the edit-test loop would need Node, npm, and a cold rebuild on every change. With classic scripts any component edit is a browser refresh.
  • Review surface: every .js file is human-readable, reviewable, and identical to what the browser runs.
  • Vendor tracking: vendor/MANIFEST.md records URL, version, and licence for every third-party library. iso-builder/build.sh refreshes the cache on request; git records the source-tree copy.

The trade-off is no tree-shaking and no TypeScript. The dashboard is small enough that neither costs us much — total JS weight under /components + /js is ~1300 lines.


Instead of ES modules, components communicate through a single well-known global. The most important keys:

Key Set in Purpose
Synvirt.api js/api.js HTTP client; every request goes through here
Synvirt.t(key, vars) js/strings.js String lookup with interpolation
Synvirt.router js/router.js Current route, onChange, navigate
Synvirt.components each components/*.js Map of component name → definition object
Synvirt.buildStores(pinia, Pinia) js/store.js Attaches all Pinia stores
Synvirt.useConsoleStore(pinia) public/lib/console-store.js Factory returning the shared console store

The global pattern keeps the scripts self-contained without module loaders, and any future migration to ES modules is a mechanical rename.


One request(method, path, body) function wraps fetch() with:

  • Basic-Auth header pulled from sessionStorage (synvirt.basic)
  • JSON body + Content-Type when body is given
  • credentials: "same-origin" (the daemon is the only origin)
  • 401 handling: clear the stored header, fire synvirt:unauthorized, reject with a structured error
  • Error shape: { kind: "api"|"network"|"unauthorized"|"aborted", status, code, message, trace_id, details } — never a raw Response or Error, so callers never have to know about fetch

The exported Synvirt.api object then exposes one async function per endpoint. Sample surface (0.8.0):

health · info · whoami — unauthenticated
listVms · getVm · createVm · startVm · shutdownVm · forceOffVm
rebootVm · deleteVm · changeIso
getUsbPolicy · setUsbPolicy — 0.8.0
listStorage · createStorage · deleteStorage
listAvailableDisks · listNetworks · listIsos
uploadIso(file, onProgress) · deleteIso — uses XMLHttpRequest
so the modal can show
upload progress
getTicket · wsUrl(path, ticket) — 0.8.0 console tickets

Trace IDs bubble back on the error object from either the canonical JSON envelope (error.trace_id) or the fallback X-Trace-Id response header, so components can render “Contact support with trace id …” without parsing headers themselves.


One store per domain. Components never keep fetched data in local reactive() — the stores are the single source of truth so two views of the same VM always render the same state.

Store Sources Lifetime
useSession whoami Session (until logout)
useVms listVms + getVm via SSE Live
useStorage listStorage Live
useNetworks listNetworks Live
useIsos listIsos Live
useToasts in-memory Session

useVms exposes running / stopped getters and refresh() / refreshOne(name) actions. SSE handlers in events.js call the matching refreshOne to keep list pages live without polling.

The USB-policy drawer is intentionally not backed by a store — it fetches fresh policy on open and posts the new policy on save, so two operators editing simultaneously see a “last write wins” model rather than a stale cached copy.


Hash-based so the daemon doesn’t have to know SPA paths — everything lives under #/…. Auth guard logic:

  • Routes marked public: true (just #/login) are always reachable.
  • Any other route without a session user redirects to #/login?redirect=<original> and the login screen returns to the captured path after success.
  • #/vms/:name/console (legacy, pre-0.8.0 separate page) is rewritten to #/vms/:name — the detail page now embeds the console inline.
  • window event synvirt:unauthorized (fired by api.js on a 401) auto-navigates to #/login?redirect=<current>. This is the one seam that turns an expired session into a friendly re-prompt.

Current route set:

#/login · #/ · #/vms · #/vms/new · #/vms/:name · #/storage
#/networks · #/isos · #/settings

EventSource would have been the natural choice, but it can’t carry our Authorization: Basic … header — EventSource only sends cookies for same-origin credentials. So events.js uses fetch() with a streaming body reader and parses SSE frames by hand.

Event kinds dispatched into stores:

Kind Action
vm_created, vm_state_changed vms.refreshOne(name)
vm_deleted splice out of vms.items
pool_created, pool_deleted storage.refresh()
iso_uploaded, iso_deleted isos.refresh()
anything else re-emit as window.dispatchEvent('synvirt:event', …)

The connection reopens with exponential backoff from 500 ms up to 15 s. logout() calls stop() and clears the retry flag so the browser doesn’t keep hammering /api/v1/events after sign-out.


The console is the only part of the dashboard that talks to a non-HTTP protocol, and it lives in its own public/ sub-tree so the rest of the app can be audited without reading WebSocket plumbing.

VmDetail "Graphics Console" button
├── user in the main dashboard tab → ConsoleModal
│ reuses Synvirt.useConsoleStore(pinia) and mounts
│ <synvirt-console-modal> on top of the VmDetail page
└── user clicks "Pop out" → opens /console.html?vm=<uuid>
new window, own Pinia, own useConsoleStore instance,
but same Synvirt.api / same ticket endpoints
both paths:
1. POST /api/console/{vm_uuid} → ticket (HS256, 30 s TTL)
2. new WebSocket(Synvirt.api.wsUrl("/console/vnc/" + uuid, ticket))
3. dynamic import('/public/lib/vnc-session.js')
→ wraps noVNC against the WS
4. resize-observer.js keeps the framebuffer sized to viewport
5. toolbar sends fullscreen / send-keys events out of band

SPICE was removed in 0.9.0-alpha.2. The previously vendored spice-html5 tree, ConsoleModal.js’s SPICE bridge, the per-VM SPICE Unix sockets, and the per-VM USB-policy drawer (which was SPICE-redirection-shaped) are all gone. USB attach/detach moves to a libvirt-native <hostdev> flow on the VM detail page.

Full protocol + security model: docs/console-architecture.md.


9. Strings and i18n (assets/strings/en_us.json)

Section titled “9. Strings and i18n (assets/strings/en_us.json)”

All user-visible text lives in en_us.json. Components call Synvirt.t("some.key", { vars }). Missing keys surface the key itself at render time, which makes them trivial to find during review.

Spanish (es_MX.json) is reserved for a future phase. Nothing in the dashboard hardcodes English outside this catalog — if you need to add a string, add the key, render via t(), and let a translator fill es_MX.json later.


10. Theme + styling (assets/css/theme.css)

Section titled “10. Theme + styling (assets/css/theme.css)”

A single CSS file with variables drives every component. The inline <script> at the top of index.html sets document.documentElement.dataset.theme to light or dark before CSS loads, using the operator’s persisted preference (synvirt.theme in localStorage) and falling back to prefers-color-scheme. Light is the default shipping theme (landed in 0.7.5); the dark-theme alternate is deferred.

Design tokens (all referenced as var(--name), never hard-coded in components):

--bg-base #f5f7fb surface behind cards
--bg-elevated #ffffff card background
--bg-raised #eef2f7 subtle contrast on elevated surfaces
--text-primary #0f172a
--text-secondary · --muted
--accent #0566C1 (SYNNET brand) · --accent-cyan #06b6d4 (secondary, not brand)
--ok #10b981 · --warn #f59e0b · --danger #ef4444

Fonts: Geist (display + body) and JetBrains Mono (monospace technical values). Both served as .woff2 from /assets/fonts/ — no CDN at runtime.

Icons: Lucide SVG sprites fetched at build time into vendor/lucide/sprite.svg; the <synvirt-icon> component (Icon.js) renders <use xlink:href="…#name"/> references.

Spec: docs/FRONTEND_PRINCIPLES.md holds the canonical style rules (vocabulary translation, five-second rule, typed-name confirmation, loading tiers, etc.) — treat this file as the how-we-render counterpart.


  1. Create crates/web-ux/components/MyThing.js.
  2. In the file, register the component onto Synvirt.components:
    (function () {
    const Synvirt = (window.Synvirt = window.Synvirt || {});
    Synvirt.components = Synvirt.components || {};
    Synvirt.components["synvirt-my-thing"] = {
    props: { … },
    emits: [ … ],
    data() { return { … } },
    template: /* html */ ``,
    };
    })();
  3. Add a <script src="/components/MyThing.js"></script> to index.html in component-dependency order.
  4. Use it in a parent template as <synvirt-my-thing …>.

js/app.js iterates over Synvirt.components at mount time and registers each entry with the Vue app, so no further wiring is needed.

Do not use SFC (.vue) files — the dashboard deliberately avoids a bundler.


Symptom First place to look
Blank dashboard with 404s in DevTools iso-builder/build.sh didn’t stage a file. Check that the .js is both in the filesystem and referenced in index.html.
“Login” keeps bouncing /api/v1/whoami returned non-200 on restore. Check daemon logs with the trace_id from the DevTools network tab.
SSE seems to die after 30 s idle Most often a proxy timeout between the browser and the daemon. The backoff reconnect will recover; if it’s consistent, raise the reverse-proxy read timeout.
Console shows a black framebuffer VNC listener probably isn’t bound yet. SSH to the appliance and virsh vncdisplay <vm> — if it returns nothing, the domain doesn’t carry a <graphics type='vnc'> block (likely a 0.8.x VM still on the SPICE shape). Re-define the VM through the migrator’s video_migration pass.

End of WEB_UX.md — keep it aligned with the shipping behaviour. If you add a store, a route, or a /public/ sub-module, document it here in the same pass.