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.
SynVirt Web UI (crates/web-ux)
Section titled “SynVirt Web UI (crates/web-ux)”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.
1. On-disk layout
Section titled “1. On-disk layout”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, noVNCEverything 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).
2. How a page boots
Section titled “2. How a page boots” 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>Why classic scripts + no build?
Section titled “Why classic scripts + no build?”- 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
.jsfile is human-readable, reviewable, and identical to what the browser runs. - Vendor tracking:
vendor/MANIFEST.mdrecords URL, version, and licence for every third-party library.iso-builder/build.shrefreshes 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.
3. Global namespace: window.Synvirt
Section titled “3. Global namespace: window.Synvirt”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.
4. HTTP client (js/api.js)
Section titled “4. HTTP client (js/api.js)”One request(method, path, body) function wraps fetch() with:
- Basic-Auth header pulled from
sessionStorage(synvirt.basic) - JSON body +
Content-Typewhenbodyis 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 rawResponseorError, 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 — unauthenticatedlistVms · getVm · createVm · startVm · shutdownVm · forceOffVmrebootVm · deleteVm · changeIsogetUsbPolicy · setUsbPolicy — 0.8.0listStorage · createStorage · deleteStoragelistAvailableDisks · listNetworks · listIsosuploadIso(file, onProgress) · deleteIso — uses XMLHttpRequest so the modal can show upload progressgetTicket · wsUrl(path, ticket) — 0.8.0 console ticketsTrace 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.
5. State: Pinia stores (js/store.js)
Section titled “5. State: Pinia stores (js/store.js)”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.
6. Router (js/router.js)
Section titled “6. Router (js/router.js)”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.windoweventsynvirt:unauthorized(fired byapi.json 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 · #/settings7. Live updates: SSE (js/events.js)
Section titled “7. Live updates: SSE (js/events.js)”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.
8. The VNC console (0.9.0-alpha.1+)
Section titled “8. The VNC console (0.9.0-alpha.1+)”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 bandSPICE 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 #ef4444Fonts: 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.
11. How to add a component
Section titled “11. How to add a component”- Create
crates/web-ux/components/MyThing.js. - 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 */ `…`,};})(); - Add a
<script src="/components/MyThing.js"></script>toindex.htmlin component-dependency order. - 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.
12. Debugging tips
Section titled “12. Debugging tips”| 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.