From 525bd68b00462090baa711ddf9fe976c1a020775 Mon Sep 17 00:00:00 2001 From: James Reilly Date: Sat, 26 Sep 2026 02:06:34 +0000 Subject: [PATCH 1/7] feat(web): command palette, tree filter and keyboard shortcuts Ctrl/Cmd+K opens a command palette that searches VMs, CTs, nodes, pools, views and actions ("stop web-prod", "migrate db-prod", "create VM"). Items run from it are remembered in localStorage and rank first. The palette is a native with a combobox/listbox, so screen readers follow the active result. Single-key shortcuts, ignored while typing in a field or a console: ? shortcut overlay, / focus the new tree filter, c console, s start/stop, g d datacenter. Read-only callers are offered no action that mutates, and the Create CT button is now hidden from them like Create VM. The ui-smoke "command-palette" check presses Ctrl+K, types a demo VM name, presses Enter and asserts that VM is selected; it saves command-palette.png and shortcuts.png, which the workflow uploads. No build step and no new dependency (ADR-0004): palette.js is a native ES module embedded with the rest of static/. Hive-Run: tuna-os/corral#349 Hive-Plan: tuna-os/corral#349 web UI command palette Hive-Spec: tuna-os/corral#349#acceptance-command-palette Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: James Reilly --- .github/workflows/ui-smoke.yml | 10 + .gitignore | 3 + docs/user-guide.md | 19 ++ pkg/web/static/app.js | 98 +++++++++- pkg/web/static/palette.js | 345 +++++++++++++++++++++++++++++++++ pkg/web/static/style.css | 92 +++++++++ scripts/ui-smoke.mjs | 55 ++++++ 7 files changed, 617 insertions(+), 5 deletions(-) create mode 100644 pkg/web/static/palette.js diff --git a/.github/workflows/ui-smoke.yml b/.github/workflows/ui-smoke.yml index c8e4fd8a..1b1c17fa 100644 --- a/.github/workflows/ui-smoke.yml +++ b/.github/workflows/ui-smoke.yml @@ -58,3 +58,13 @@ jobs: name: grid-columns path: grid-columns.png if-no-files-found: ignore + + # Named checks save what they saw (e.g. command-palette.png), so a + # reviewer can look at a UI change without running it. + - name: Upload smoke screenshots + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ui-smoke-screenshots + path: ui-smoke-screenshots/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f2d1843c..4c51fe62 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ corral-incus # Coverage profiles (scripts/coverage-gate.sh, just cover) cover.out + +# UI smoke screenshots (scripts/ui-smoke.mjs) +ui-smoke-screenshots/ diff --git a/docs/user-guide.md b/docs/user-guide.md index f7507ccf..7e6499b7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -79,6 +79,25 @@ Access the Proxmox-style Web UI at `http://localhost:8006` or via `corral web`. ![Mobile Dashboard](screenshots/dashboard-mobile.png) +#### Web keyboard shortcuts + +Press `Ctrl+K` (`⌘K` on macOS) to open the command palette. Type part of a +name to find a VM, a CT, a node or a pool. Type a verb and a name to find an +action, for example `stop web-prod`. Press `Enter` to run the first result. +The items that you used last show at the top of the list. + +| Key | Action | +|---|---| +| `Ctrl+K` | Open the command palette | +| `?` | Show the list of shortcuts | +| `/` | Filter the tree by guest name | +| `c` | Open the console of the selected VM | +| `s` | Start or stop the selected VM | +| `g` then `d` | Go to the datacenter | + +The one-key shortcuts do not operate when you type in a field or in a console. +A read-only user does not see the actions that change a guest. + #### Theme & Branding In Corral's web UI, you can fully customize the accent colour, the header diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index 76d5cdc8..bf20d194 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -2,8 +2,9 @@ // Vanilla JS; noVNC + xterm.js vendored under static/vendor/ (offline-safe). import { icon } from './icons.js'; -import { bindPools, loadPools, renderTreePools } from './pools.js'; +import { bindPools, loadPools, poolState, renderTreePools } from './pools.js'; import { mountGrid } from './grid.js'; +import { bindPalette, initKeys } from './palette.js'; const $ = (sel) => document.querySelector(sel); @@ -312,9 +313,45 @@ function treeViewToggle() { return div; } +// The tree filter (`/` focuses it) narrows the guest rows by name. It is built +// once and kept across re-renders: the 5s poll rebuilds the tree, and a +// rebuilt input would drop focus and the caret mid-word. +let treeFilter = ''; +let treeFilterEl = null; + +function treeFilterBox() { + if (treeFilterEl) return treeFilterEl; + treeFilterEl = document.createElement('input'); + treeFilterEl.id = 'tree-filter'; + treeFilterEl.type = 'search'; + treeFilterEl.placeholder = 'Filter guests… /'; + treeFilterEl.setAttribute('aria-label', 'Filter the tree by guest name'); + treeFilterEl.addEventListener('input', () => { treeFilter = treeFilterEl.value.trim().toLowerCase(); applyTreeFilter(); }); + treeFilterEl.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { treeFilterEl.value = ''; treeFilter = ''; applyTreeFilter(); treeFilterEl.blur(); } + }); + return treeFilterEl; +} + +function applyTreeFilter() { + $('#tree').querySelectorAll('.tree-item[data-guest]').forEach((row) => { + row.hidden = !!treeFilter && !row.dataset.guest.toLowerCase().includes(treeFilter); + }); +} + +function focusTreeFilter() { + $('#tree').classList.add('open'); // the drawer, on a phone + const el = treeFilterBox(); + el.focus(); + el.select(); +} + function renderTree() { const tree = $('#tree'); - tree.replaceChildren(); + const filter = treeFilterBox(); + // Everything but the filter box goes; removing a focused input blurs it. + for (const child of [...tree.children]) if (child !== filter) child.remove(); + if (!filter.isConnected) tree.appendChild(filter); tree.appendChild(treeViewToggle()); tree.appendChild(treeRow({ @@ -364,19 +401,22 @@ function renderTree() { if (treeView === 'pool') renderTreePools(tree); else if (treeView === 'namespace') renderTreeNamespaces(tree); else renderTreeServer(tree); + applyTreeFilter(); } // CTs sit in the same per-node/per-namespace groups as VMs, distinguished // only by icon — matching real Proxmox, which puts VMs and CTs in one // resource tree per node/pool rather than segregating them. function ctRow(c, lvl) { - return treeRow({ + const row = treeRow({ lvl, icon: icon('container'), label: c.name, sub: c.namespace, dot: c.ready ? 'on' : c.phase === 'Stopped' ? 'off' : 'mid', sel: selected.type === 'ct' && selected.key === ctKey(c), onclick: () => select({ type: 'ct', key: ctKey(c) }), }); + row.dataset.guest = c.name; + return row; } // Server View: Datacenter → Node → VMs/CTs, grouped by .node. Guests with @@ -465,6 +505,7 @@ function vmRow(vm, lvl) { renderContent(); }; row.prepend(check); + row.dataset.guest = vm.name; return row; } @@ -474,16 +515,41 @@ function markRendered() { lastRenderFp = JSON.stringify([vms, cts, nodes, selected, tab]); } -function select(sel) { +function select(sel, openTab = 'summary') { disconnectConsoles(); if (selected.type === 'multiview' && sel.type !== 'multiview') disconnectMultiview(); selected = sel; - tab = 'summary'; + tab = openTab; renderTree(); renderContent(); markRendered(); } +// openVM selects a VM and, optionally, one of its tabs. "console" means the +// best console the VM has: VNC when it has one, the serial terminal if not. +function openVM(key, wantTab) { + const vm = findVM(key); + if (!vm) return; + const cap = vm.capabilities || {}; + const t = wantTab === 'console' && !cap.vnc && cap.tty ? 'terminal' : (wantTab || 'summary'); + // Already there: leave a live console connected rather than reconnecting it. + if (selected.type === 'vm' && selected.key === key && tab === t) return; + select({ type: 'vm', key }, t); +} + +// openPool shows a pool the only place pools are drawn: the Pool View tree. +async function openPool(path) { + if (treeView !== 'pool') setTreeView('pool'); + await loadPools(); + renderTree(); + const row = [...$('#tree').querySelectorAll('.tree-item')].find((r) => r.title === path); + if (!row) return; + $('#tree').classList.add('open'); + row.scrollIntoView({ block: 'nearest' }); + row.classList.add('flash'); + setTimeout(() => row.classList.remove('flash'), 1500); +} + // ── Content panel ───────────────────────────────────────────────── function renderContent() { @@ -1642,6 +1708,7 @@ async function renderPowerSchedule(vm) { if (!box) return; let s = {}; try { s = await api(vmURL(vm, '/powerschedule')); } catch { /* form */ } + if (!box.isConnected) return; // the tab changed while this loaded const has = s && (s.start || s.stop); box.innerHTML = `

${icon('play')} Autostart / shutdown windows

@@ -2907,6 +2974,27 @@ $('#btn-create').innerHTML = `${icon('plus')} Create VM`; // drop onto one means, not how it looks. bindPools({ api, toast, esc, icon, refresh, treeRow, vmRow }); +// The palette reads the fleet through getters: the poll replaces these arrays +// rather than mutating them, so a captured reference would go stale. +bindPalette({ + esc, icon, vmKey, + vms: () => vms, + cts: () => cts, + nodes: () => nodes, + pools: poolState, + ensurePools: async () => { if (!(poolState().folders || []).length) { await loadPools(); } }, + go: select, + openVM, + openPool, + vmAction: (key, act) => { const vm = findVM(key); if (vm) vmAction(vm, act); }, + ctAction: (key, act) => { const c = findCT(key); if (c) ctAction(c, act); }, + createVM: () => $('#btn-create').click(), + createCT: () => $('#btn-create-ct').click(), + selectedVMKey: () => (selected.type === 'vm' ? selected.key : null), + focusFilter: focusTreeFilter, +}); +initKeys(); + loadWhoami(); loadCaps(); loadInstanceTypes(); diff --git a/pkg/web/static/palette.js b/pkg/web/static/palette.js new file mode 100644 index 00000000..3dbd8bcd --- /dev/null +++ b/pkg/web/static/palette.js @@ -0,0 +1,345 @@ +// Command palette (Ctrl/Cmd+K), keyboard shortcuts and their `?` overlay (#349). +// +// The palette searches the same things the tree shows — VMs, CTs, nodes and +// pools — plus the verbs an operator reaches for ("stop web-prod", "migrate +// db-prod", "create VM"). Whatever was run from it recently ranks first, so +// the second visit to a VM is two keystrokes. +// +// It is a keyboard equivalent, never a second way around the rules: an action +// that mutates is not offered to a read-only caller, exactly as the toolbar +// that holds the same buttons is hidden from them. + +// Injected by app.js so this module does not import the entry point back. +let ctx = {}; +export function bindPalette(helpers) { ctx = helpers; } + +const RECENT_KEY = 'corral.palette.recent'; +const RECENT_MAX = 20; +const RESULTS_MAX = 50; + +function loadRecent() { + try { + const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); + return Array.isArray(list) ? list.filter((id) => typeof id === 'string') : []; + } catch { return []; } +} + +function remember(id) { + const list = [id, ...loadRecent().filter((x) => x !== id)].slice(0, RECENT_MAX); + try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* private mode */ } +} + +const readOnly = () => document.body.classList.contains('read-only'); + +// ── entries ─────────────────────────────────────────────────────── +// Each entry: { id, kind, label, sub, keywords, mutates, run }. The id is +// stable across polls so "recently used" survives a refresh and a reload. + +function entries() { + const { vms, cts, nodes, pools, go, openVM, vmAction, ctAction, openPool, createVM, createCT, icon } = ctx; + const out = []; + const add = (e) => out.push({ keywords: '', mutates: false, ...e }); + + const views = [ + ['dc', 'Datacenter', 'datacenter'], + ['doctor', 'Cluster health', 'health'], + ['extensions', 'Extensions', 'extension'], + ['multiview', 'Multiview', 'cube'], + ['settings', 'Settings', 'cog'], + ]; + for (const [type, label, ic] of views) { + add({ id: `view:${type}`, kind: 'view', icon: icon(ic), label, sub: 'go to', run: () => go({ type }) }); + } + add({ id: 'action:create-vm', kind: 'create', icon: icon('plus'), label: 'Create VM', sub: 'new virtual machine', keywords: 'new', mutates: true, run: createVM }); + add({ id: 'action:create-ct', kind: 'create', icon: icon('plus'), label: 'Create CT', sub: 'new container', keywords: 'new container', mutates: true, run: createCT }); + + for (const vm of vms()) { + const key = ctx.vmKey(vm); + const where = [vm.backend, vm.namespace, vm.node].filter(Boolean).join(' · '); + add({ id: `vm:${key}`, kind: 'vm', icon: icon(vm.isTemplate ? 'template' : 'cube'), label: vm.name, sub: where, keywords: (vm.tags || []).join(' '), run: () => openVM(key) }); + + const cap = vm.capabilities || {}; + const verb = (act, label, when = true) => { + if (!when) return; + add({ + id: `vm-${act}:${key}`, kind: 'action', icon: icon({ console: 'desktop', start: 'play' }[act] || act), + label: `${label} ${vm.name}`, sub: where, mutates: act !== 'console', + run: () => (act === 'console' ? openVM(key, 'console') : vmAction(key, act)), + }); + }; + verb('console', 'Console', !!(cap.vnc || cap.tty)); + verb('start', 'Start', !!cap.start && !vm.running); + verb('stop', 'Stop', !!cap.stop && vm.running); + verb('restart', 'Restart', !!(cap.start && cap.stop) && vm.running); + verb('migrate', 'Migrate', vm.backend === 'kubevirt' && vm.ready && vm.liveMigratable); + } + + for (const c of cts()) { + const key = `${c.namespace}/${c.name}`; + add({ id: `ct:${key}`, kind: 'ct', icon: icon('container'), label: c.name, sub: `container · ${c.namespace}`, run: () => go({ type: 'ct', key }) }); + const running = c.ready || c.phase === 'Running'; + add({ + id: `ct-${running ? 'stop' : 'start'}:${key}`, kind: 'action', icon: icon(running ? 'stop' : 'play'), + label: `${running ? 'Stop' : 'Start'} ${c.name}`, sub: `container · ${c.namespace}`, mutates: true, + run: () => ctAction(key, running ? 'stop' : 'start'), + }); + } + + for (const n of nodes()) { + add({ id: `node:${n.name}`, kind: 'node', icon: icon('server'), label: n.name, sub: `node${n.roles ? ` · ${n.roles}` : ''}`, run: () => go({ type: 'node', name: n.name }) }); + } + + for (const f of pools().folders || []) { + add({ id: `pool:${f.path}`, kind: 'pool', icon: icon('folder'), label: f.path, sub: 'pool', run: () => openPool(f.path) }); + } + + return readOnly() ? out.filter((e) => !e.mutates) : out; +} + +// ── matching ────────────────────────────────────────────────────── +// Subsequence match, scored so a prefix beats a word start beats a scatter. +// Every query word must match somewhere in the entry; "stop web" finds +// "Stop web-prod" and "web st" does too. + +function scoreWord(word, text, scatter) { + const idx = text.indexOf(word); + if (idx === 0) return 100; + if (idx > 0) return /[\s\-_./·]/.test(text[idx - 1]) ? 80 : 60; + if (!scatter) return 0; + let t = 0; + let gaps = 0; + for (const ch of word) { + const at = text.indexOf(ch, t); + if (at < 0) return 0; + gaps += at - t; + t = at + 1; + } + return Math.max(1, 40 - gaps); +} + +// With nothing typed, the list reads top-down: places, then the create verbs, +// then the fleet. Per-guest verbs come last; they are for searching. +const BROWSE_ORDER = { view: 50, create: 40, action: 0, vm: 30, ct: 20, node: 10, pool: 10 }; + +export function rank(list, query, recent) { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const recency = new Map(recent.map((id, i) => [id, recent.length - i])); + const scored = []; + for (const e of list) { + const label = e.label.toLowerCase(); + const hay = `${label} ${(e.sub || '').toLowerCase()} ${e.keywords.toLowerCase()} ${e.kind}`; + let score = 0; + let ok = true; + for (const w of words) { + // Letters scattered across the name ("dbp" → db-prod) count; scattered + // across the namespace, node and tags they would match almost anything. + const s = Math.max(scoreWord(w, label, true) * 1.5, scoreWord(w, hay, false)); + if (!s) { ok = false; break; } + score += s; + } + if (!ok) continue; + // An exact name beats anything else: typing a VM's whole name and hitting + // Enter must land on that VM, not on "Stop ". + if (words.length && label === words.join(' ')) score += 1000; + const r = recency.get(e.id) || 0; + // Recently used ranks first when browsing (empty query) and breaks near- + // ties when searching, without letting a stale hit outrank a real match. + score += r ? (words.length ? 30 + r : 10000 + r) : 0; + if (!words.length) score += BROWSE_ORDER[e.kind] || 0; + scored.push({ e, score }); + } + scored.sort((a, b) => b.score - a.score || a.e.label.localeCompare(b.e.label)); + return scored.slice(0, RESULTS_MAX).map((s) => s.e); +} + +// ── palette dialog ──────────────────────────────────────────────── + +let dlg = null; +let results = []; +let active = 0; + +function build() { + dlg = document.createElement('dialog'); + dlg.id = 'palette'; + dlg.setAttribute('aria-label', 'Command palette'); + dlg.innerHTML = ` + +
    +
    ↑↓ move · Enter run · Esc close · ? shortcuts
    `; + document.body.appendChild(dlg); + + const input = dlg.querySelector('#palette-input'); + input.addEventListener('input', () => { active = 0; update(); }); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { move(1); e.preventDefault(); } + else if (e.key === 'ArrowUp') { move(-1); e.preventDefault(); } + else if (e.key === 'Enter') { runActive(); e.preventDefault(); } + }); + // A click on the backdrop lands on the dialog itself; one inside the card + // lands on a child. + dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); + dlg.querySelector('#palette-list').addEventListener('click', (e) => { + const li = e.target.closest('li[data-i]'); + if (!li) return; + active = Number(li.dataset.i); + runActive(); + }); +} + +function update() { + const input = dlg.querySelector('#palette-input'); + const list = dlg.querySelector('#palette-list'); + const recent = loadRecent(); + results = rank(entries(), input.value, recent); + if (active >= results.length) active = Math.max(0, results.length - 1); + const { esc } = ctx; + list.innerHTML = results.length + ? results.map((e, i) => `
  • + ${e.icon}${esc(e.label)} + ${esc(e.sub || '')} + ${recent.includes(e.id) ? 'recent' : ''}
  • `).join('') + : '
  • No matches.
  • '; + if (results.length) input.setAttribute('aria-activedescendant', `palette-opt-${active}`); + else input.removeAttribute('aria-activedescendant'); +} + +function move(delta) { + if (!results.length) return; + active = (active + delta + results.length) % results.length; + const list = dlg.querySelector('#palette-list'); + list.querySelectorAll('li[data-i]').forEach((li) => { + const on = Number(li.dataset.i) === active; + li.classList.toggle('active', on); + li.setAttribute('aria-selected', String(on)); + if (on) li.scrollIntoView({ block: 'nearest' }); + }); + dlg.querySelector('#palette-input').setAttribute('aria-activedescendant', `palette-opt-${active}`); +} + +function runActive() { + const e = results[active]; + if (!e) return; + remember(e.id); + dlg.close(); + e.run(); +} + +export async function openPalette() { + if (!dlg) build(); + closeShortcuts(); + if (!dlg.open) dlg.showModal(); + const input = dlg.querySelector('#palette-input'); + input.value = ''; + active = 0; + update(); + input.focus(); + // Pools are only polled while Pool View is showing; fetch them once so the + // palette can find one from any view. + await ctx.ensurePools(); + if (dlg.open) update(); +} + +// ── shortcut overlay ────────────────────────────────────────────── + +const SHORTCUTS = [ + [['Ctrl', 'K'], 'Open the command palette (⌘K on macOS)'], + [['?'], 'Show this list'], + [['/'], 'Filter the tree'], + [['c'], 'Open the selected VM\'s console'], + [['s'], 'Start or stop the selected VM'], + [['g', 'd'], 'Go to the datacenter'], + [['Esc'], 'Close a dialog'], +]; + +let help = null; + +function closeShortcuts() { if (help?.open) help.close(); } + +export function openShortcuts() { + if (!help) { + help = document.createElement('dialog'); + help.id = 'shortcuts'; + help.setAttribute('aria-labelledby', 'shortcuts-title'); + help.innerHTML = ` +

    Keyboard shortcuts

    +
    ${SHORTCUTS.map(([keys, what]) => + `
    ${keys.map((k) => `${k}`).join(' then ')}
    ${what}
    `).join('')}
    +

    Single-key shortcuts are ignored while you type in a field or a console.

    +
    `; + document.body.appendChild(help); + help.querySelector('#shortcuts-close').onclick = () => help.close(); + help.addEventListener('click', (e) => { if (e.target === help) help.close(); }); + } + // Start/stop mutates, so a read-only caller is not told it exists. + help.querySelectorAll('dt').forEach((dt) => { + const mutating = dt.textContent.trim() === 's'; + dt.hidden = mutating && readOnly(); + dt.nextElementSibling.hidden = dt.hidden; + }); + if (!help.open) help.showModal(); +} + +// ── global keys ─────────────────────────────────────────────────── + +// A key typed into a field, or into a live console, belongs to that field: +// `s` in a VM name is a letter, and Ctrl+K in a serial shell kills a line. +function typing(target) { + if (!(target instanceof Element)) return false; + if (target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return true; + return !!target.closest('#vnc-screen, #tty-screen, #rdp-screen, .xterm'); +} + +let pendingG = 0; + +function onKey(e) { + if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { + // Inside the palette's own box Ctrl+K reopens it, which is harmless; in a + // console it is the console's key. + if (typing(e.target) && e.target.id !== 'palette-input') return; + // Another dialog (the create wizard, a move preflight) owns the screen; + // jumping elsewhere from behind it would strand it. + if (document.querySelector('dialog[open]:not(#palette):not(#shortcuts)') || document.querySelector('.modal')) return; + e.preventDefault(); + openPalette(); + return; + } + if (e.ctrlKey || e.metaKey || e.altKey || e.defaultPrevented) return; + if (typing(e.target)) return; + // A modal owns the keyboard while it is open; the overlay's own keys aside. + if (document.querySelector('dialog[open]:not(#shortcuts)') || document.querySelector('.modal')) return; + + if (pendingG && Date.now() - pendingG < 1200) { + pendingG = 0; + if (e.key === 'd') { e.preventDefault(); closeShortcuts(); ctx.go({ type: 'dc' }); } + return; + } + pendingG = 0; + + switch (e.key) { + case '?': e.preventDefault(); openShortcuts(); break; + case '/': e.preventDefault(); closeShortcuts(); ctx.focusFilter(); break; + case 'g': pendingG = Date.now(); break; + case 'c': { + const key = ctx.selectedVMKey(); + if (key) { e.preventDefault(); ctx.openVM(key, 'console'); } + break; + } + case 's': { + if (readOnly()) break; + const key = ctx.selectedVMKey(); + const vm = key && ctx.vms().find((v) => ctx.vmKey(v) === key); + if (!vm) break; + const cap = vm.capabilities || {}; + const act = vm.running ? 'stop' : 'start'; + if (!cap[act]) break; + e.preventDefault(); + ctx.vmAction(key, act); + break; + } + } +} + +export function initKeys() { document.addEventListener('keydown', onKey); } diff --git a/pkg/web/static/style.css b/pkg/web/static/style.css index ec324a70..f2ccd80a 100644 --- a/pkg/web/static/style.css +++ b/pkg/web/static/style.css @@ -180,6 +180,7 @@ main { /* In read-only mode the server rejects mutations anyway — hide the controls that would only ever 403, leaving view-only surfaces (consoles, tabs). */ body.read-only #btn-create, +body.read-only #btn-create-ct, body.read-only .toolbar, body.read-only .hp-action, body.read-only .bulkbar button[data-bulk] { display: none !important; } @@ -737,3 +738,94 @@ tr.ev-warn td { color: var(--yellow); } .modal-card .refusals li { color: var(--bad, #f85149); } .modal-card .check { display: block; margin: 16px 0 0; font-size: .85rem; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } + +/* ── Command palette, shortcut overlay and tree filter (#349) ── */ + +#tree-filter { + display: block; + width: 100%; + margin: 0 0 8px; + padding: 6px 9px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: .85rem; +} +#tree-filter:focus { outline: none; border-color: var(--accent); } +.tree-item.flash { outline: 2px solid var(--accent); outline-offset: -2px; } + +kbd { + display: inline-block; + min-width: 1.6em; + padding: 1px 6px; + font: .75rem ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: center; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 5px; +} + +/* The palette sits high rather than centred: the list grows downward as you + type, and a centred box would jump. */ +#palette { + width: min(640px, 94vw); + margin-top: 12vh; + padding: 0; + overflow: hidden; +} +#palette-input { + display: block; + width: 100%; + margin: 0; + padding: 14px 16px; + font-size: 1rem; + background: var(--panel); + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; +} +#palette-input:focus { outline: none; } +#palette-list { + list-style: none; + margin: 0; + padding: 6px; + max-height: min(420px, 60vh); + overflow-y: auto; +} +#palette-list li { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: var(--radius); + cursor: pointer; + white-space: nowrap; +} +#palette-list li .icon { width: 17px; height: 17px; flex: none; } +#palette-list li .palette-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +#palette-list li .muted { color: var(--muted); font-size: .8rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +#palette-list li .chip { margin-left: auto; } +#palette-list li:hover { background: var(--panel-2); } +#palette-list li.active { background: var(--accent); color: #14100b; } +#palette-list li.active .muted { color: #14100b; } +#palette-list li.palette-empty { cursor: default; } +.palette-foot { + display: flex; gap: 4px; align-items: center; flex-wrap: wrap; + padding: 8px 14px; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: .75rem; +} + +#shortcuts dl.shortcuts { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 16px; + margin: 0; + font-size: .9rem; +} +#shortcuts dt { color: var(--muted); font-size: .8rem; } +#shortcuts dd { margin: 0; } diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index b1d34f3c..15dbca43 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -12,6 +12,8 @@ import { mkdir } from 'node:fs/promises'; import { chromium } from 'playwright'; const BASE = process.env.CORRAL_URL || 'http://127.0.0.1:8899/'; +// Named checks save a screenshot here; the workflow uploads the directory. +const SHOTS = process.env.UI_SMOKE_SHOTS || 'ui-smoke-screenshots'; let failures = 0; const check = (ok, msg) => { console.log(`${ok ? 'ok' : 'FAIL'} - ${msg}`); @@ -110,6 +112,59 @@ const broken = await page.locator('.doc-broken').allTextContents(); const clusterBroken = broken.filter((t) => /KubeVirt|CDI|StorageClass|Snapshot|Export|metrics/i.test(t)); check(clusterBroken.length === 0, `cluster checks green in demo (${clusterBroken.join('; ').slice(0, 120)})`); +// ── command-palette (#349) ──────────────────────────────────────── +// Ctrl+K, a demo VM's name, Enter: that VM is selected. Starts from Cluster +// health, so a pass means the palette moved the selection, not that it was +// already there. +await page.keyboard.press('Control+k'); +await page.waitForSelector('#palette[open] #palette-input', { timeout: 5000 }).catch(() => {}); +check(await page.locator('#palette[open]').count() === 1, 'command-palette: Ctrl+K opens the palette'); +await page.keyboard.type('db-prod'); +check( + (await page.textContent('#palette-list li.active .palette-label').catch(() => '')) === 'db-prod', + 'command-palette: typing a VM name puts that VM first', +); +check(await page.locator('#palette-list li', { hasText: 'Stop db-prod' }).count() === 1, 'command-palette: actions are searchable'); +await page.keyboard.press('Enter'); +await page.waitForTimeout(800); +check(await page.locator('#palette[open]').count() === 0, 'command-palette: Enter closes the palette'); +check(await page.locator('#tree .tree-item.selected', { hasText: 'db-prod' }).count() === 1, 'command-palette: Enter selects the VM in the tree'); +check( + (await page.textContent('.page-head h1')).includes('db-prod'), + 'command-palette: Enter opens the VM', +); +await mkdir(SHOTS, { recursive: true }); +await page.keyboard.press('Control+k'); +await page.waitForSelector('#palette[open]', { timeout: 5000 }).catch(() => {}); +check( + (await page.textContent('#palette-list li.active .palette-label').catch(() => '')) === 'db-prod', + 'command-palette: the recently used VM ranks first', +); +await page.keyboard.type('web'); +await page.screenshot({ path: `${SHOTS}/command-palette.png` }); +await page.keyboard.press('Escape'); +check(await page.locator('#palette[open]').count() === 0, 'command-palette: Escape closes the palette'); + +// `?` lists the shortcuts; `/` focuses the tree filter, which narrows guests. +await page.locator('body').focus(); +await page.keyboard.press('?'); +check(await page.locator('#shortcuts[open]').count() === 1, 'shortcuts: ? opens the shortcut overlay'); +await page.screenshot({ path: `${SHOTS}/shortcuts.png` }); +await page.keyboard.press('Escape'); +await page.keyboard.press('/'); +check(await page.evaluate(() => document.activeElement?.id) === 'tree-filter', 'shortcuts: / focuses the tree filter'); +await page.keyboard.type('db-pr'); +check( + await page.locator('#tree .tree-item[data-guest]:visible').count() === 1, + 'tree filter narrows the tree to matching guests', +); +await page.keyboard.press('Escape'); +check(await page.locator('#tree .tree-item[data-guest]:visible').count() > 1, 'Escape clears the tree filter'); +await page.keyboard.press('g'); +await page.keyboard.press('d'); +await page.waitForTimeout(500); +check(await page.locator('#tree .tree-item.selected', { hasText: 'Datacenter' }).count() === 1, 'shortcuts: g d goes to the datacenter'); + // Create wizard opens with catalog cards. await page.click('#tree >> text=Datacenter'); await page.waitForTimeout(800); From c8145f58eef4614e976f684ab9dae4e847476b41 Mon Sep 17 00:00:00 2001 From: hanthor Date: Fri, 2 Oct 2026 00:00:36 +0000 Subject: [PATCH 2/7] test(ui-smoke): blur the focused element before pressing ? After Escape the palette returns focus to an input, where ? is text and not a shortcut, so the overlay check failed intermittently on CI. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexZnEN3jq4jRUi8T3zZuY Signed-off-by: hanthor --- scripts/ui-smoke.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index 15dbca43..c8d84869 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -146,8 +146,11 @@ await page.keyboard.press('Escape'); check(await page.locator('#palette[open]').count() === 0, 'command-palette: Escape closes the palette'); // `?` lists the shortcuts; `/` focuses the tree filter, which narrows guests. -await page.locator('body').focus(); +// Blur whatever the closed palette handed focus back to: a key typed into an +// input is text, not a shortcut. +await page.evaluate(() => document.activeElement?.blur()); await page.keyboard.press('?'); +await page.waitForSelector('#shortcuts[open]', { timeout: 3000 }).catch(() => {}); check(await page.locator('#shortcuts[open]').count() === 1, 'shortcuts: ? opens the shortcut overlay'); await page.screenshot({ path: `${SHOTS}/shortcuts.png` }); await page.keyboard.press('Escape'); From 9fdb8894f8aa8a69b07ea49dbc803d8f185cec5b Mon Sep 17 00:00:00 2001 From: James Reilly Date: Wed, 30 Sep 2026 06:53:56 +0000 Subject: [PATCH 3/7] feat(web): right-click context menus on tree rows and grid rows Add right-click and keyboard (ContextMenu key, Shift+F10) context menus to all tree rows (VMs, containers, nodes, namespaces, pools, hostpower) and grid rows (VM table, CT table, template table). The context menu follows each object's capabilities (start, stop, reboot, console, migrate, snapshot, clone, convert to template, tags, delete), positions itself within the viewport boundaries, is navigable with arrow keys / Home / End / Escape / Enter, and closes on Escape or an outside click. For nodes, the menu integrates with host-power plugins (power on/off) and surfaces cordon/drain items. For VMs, keyboard and menu equivalents for drag gestures (assign to pool, move to backend) are provided for accessibility. In read-only mode (body.read-only), all mutating actions are hidden/omitted. A smoke check in scripts/ui-smoke.mjs exercises the context menu against the demo fleet and verifies the state change. Closes #343 Hive-Run: tuna-os/corral#343 Hive-Plan: web-ui-context-menus Hive-Spec: web-ui#context-menus Signed-off-by: James Reilly --- pkg/web/static/app.js | 802 ++++++++++++++++++++++++++++++++++++++- pkg/web/static/grid.js | 1 + pkg/web/static/pools.js | 3 + pkg/web/static/style.css | 103 ++++- scripts/ui-smoke.mjs | 45 +++ 5 files changed, 935 insertions(+), 19 deletions(-) diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index bf20d194..64b06621 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -2,7 +2,7 @@ // Vanilla JS; noVNC + xterm.js vendored under static/vendor/ (offline-safe). import { icon } from './icons.js'; -import { bindPools, loadPools, poolState, renderTreePools } from './pools.js'; +import { bindPools, loadPools, poolState, renderTreePools, showMoveDialog, summariseOutcomes } from './pools.js'; import { mountGrid } from './grid.js'; import { bindPalette, initKeys } from './palette.js'; @@ -287,9 +287,725 @@ function setTreeView(v) { else renderTree(); } +// ── Context Menus ────────────────────────────────────────────────── + +let activeContextMenu = null; + +function hideContextMenu() { + if (activeContextMenu) { + activeContextMenu.el.remove(); + if (activeContextMenu.trigger && typeof activeContextMenu.trigger.focus === 'function') { + if (document.activeElement === document.body || activeContextMenu.el.contains(document.activeElement)) { + activeContextMenu.trigger.focus(); + } + } + activeContextMenu = null; + } +} + +function showContextMenu(e, items, triggerEl) { + if (e) { + if (typeof e.preventDefault === 'function') e.preventDefault(); + if (typeof e.stopPropagation === 'function') e.stopPropagation(); + } + hideContextMenu(); + + const isReadOnly = document.body.classList.contains('read-only') || !me.admin; + const rawItems = typeof items === 'function' ? items() : items; + if (!rawItems || !rawItems.length) return; + + // Filter out mutating items for read-only users + const filtered = rawItems.filter((it) => { + if (!it) return false; + if (isReadOnly && it.mutate) return false; + return true; + }); + + // Clean trailing/duplicate separators + const visible = []; + for (const it of filtered) { + if (it.separator) { + if (visible.length && !visible[visible.length - 1].separator) { + visible.push(it); + } + } else { + visible.push(it); + } + } + if (visible.length && visible[visible.length - 1].separator) { + visible.pop(); + } + if (!visible.length) return; + + const menu = document.createElement('div'); + menu.className = 'context-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('tabindex', '-1'); + + for (const it of visible) { + if (it.separator) { + const sep = document.createElement('div'); + sep.className = 'menu-separator'; + sep.setAttribute('role', 'separator'); + menu.appendChild(sep); + continue; + } + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `menu-item${it.danger ? ' danger' : ''}${it.mutate ? ' menu-mutate' : ''}`; + btn.setAttribute('role', 'menuitem'); + btn.setAttribute('tabindex', '-1'); + if (it.disabled) btn.disabled = true; + if (it.title) btn.title = it.title; + + btn.innerHTML = `${it.icon ? icon(it.icon) : ''} ${esc(it.label)}`; + btn.onclick = (ev) => { + ev.stopPropagation(); + hideContextMenu(); + if (typeof it.action === 'function') it.action(); + }; + + menu.appendChild(btn); + } + + document.body.appendChild(menu); + + // Position calculation: + let x = e?.clientX; + let y = e?.clientY; + if ((x === undefined || y === undefined || (x === 0 && y === 0)) && triggerEl) { + const rect = triggerEl.getBoundingClientRect(); + x = rect.left + 24; + y = rect.bottom; + } + x = x || 10; + y = y || 10; + + const rect = menu.getBoundingClientRect(); + const pad = 6; + if (x + rect.width > window.innerWidth - pad) { + x = Math.max(pad, window.innerWidth - rect.width - pad); + } + if (y + rect.height > window.innerHeight - pad) { + y = Math.max(pad, window.innerHeight - rect.height - pad); + } + if (x < pad) x = pad; + if (y < pad) y = pad; + + menu.style.left = `${Math.round(x)}px`; + menu.style.top = `${Math.round(y)}px`; + + activeContextMenu = { el: menu, trigger: triggerEl }; + + // Focus the first enabled menu item + const enabled = [...menu.querySelectorAll('button.menu-item:not(:disabled)')]; + if (enabled.length > 0) { + enabled[0].focus(); + } else { + menu.focus(); + } + + menu.addEventListener('keydown', (kev) => { + const buttons = [...menu.querySelectorAll('button.menu-item:not(:disabled)')]; + if (!buttons.length) return; + const currentIdx = buttons.indexOf(document.activeElement); + + if (kev.key === 'ArrowDown') { + kev.preventDefault(); + const next = (currentIdx + 1) % buttons.length; + buttons[next].focus(); + } else if (kev.key === 'ArrowUp') { + kev.preventDefault(); + const prev = (currentIdx - 1 + buttons.length) % buttons.length; + buttons[prev].focus(); + } else if (kev.key === 'Home') { + kev.preventDefault(); + buttons[0].focus(); + } else if (kev.key === 'End') { + kev.preventDefault(); + buttons[buttons.length - 1].focus(); + } else if (kev.key === 'Escape') { + kev.preventDefault(); + hideContextMenu(); + } else if (kev.key === 'Tab') { + hideContextMenu(); + } + }); +} + +function attachContextMenu(el, getItems) { + if (!el) return; + if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); + + el.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const items = typeof getItems === 'function' ? getItems() : getItems; + if (items && items.length) showContextMenu(e, items, el); + }); + + el.addEventListener('keydown', (e) => { + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + e.preventDefault(); + const items = typeof getItems === 'function' ? getItems() : getItems; + if (items && items.length) showContextMenu(e, items, el); + } + }); +} + +document.addEventListener('pointerdown', (e) => { + if (activeContextMenu && !activeContextMenu.el.contains(e.target)) { + hideContextMenu(); + } +}); +window.addEventListener('resize', hideContextMenu); +window.addEventListener('scroll', hideContextMenu, true); + +function vmMenuItems(vm) { + const capability = vm.capabilities || {}; + const isKubeVirt = vm.backend === 'kubevirt'; + const items = []; + + // Console / Terminal / RDP options + if (capability.vnc) { + items.push({ + icon: 'desktop', + label: 'Console (VNC)', + action: () => { + select({ type: 'vm', key: vmKey(vm) }); + tab = 'console'; + renderContent(); + markRendered(); + }, + }); + } + if (capability.tty) { + items.push({ + icon: 'terminal', + label: 'Terminal (Serial)', + action: () => { + select({ type: 'vm', key: vmKey(vm) }); + tab = 'terminal'; + renderContent(); + markRendered(); + }, + }); + } + if (capability.rdp) { + items.push({ + icon: 'desktop', + label: 'RDP Console', + action: () => { + select({ type: 'vm', key: vmKey(vm) }); + tab = 'rdp'; + renderContent(); + markRendered(); + }, + }); + } + if (!capability.vnc && !capability.tty && !capability.rdp) { + items.push({ + icon: 'info', + label: 'Open summary', + action: () => select({ type: 'vm', key: vmKey(vm) }), + }); + } + + items.push({ separator: true }); + + // Power actions + if (capability.start !== false) { + items.push({ + icon: 'play', + label: 'Start', + mutate: true, + disabled: !!vm.running, + action: () => vmAction(vm, 'start'), + }); + } + if (capability.stop !== false) { + items.push({ + icon: 'stop', + label: 'Stop', + mutate: true, + disabled: !vm.running, + action: () => vmAction(vm, 'stop'), + }); + } + if (capability.start !== false && capability.stop !== false) { + items.push({ + icon: 'restart', + label: 'Restart', + mutate: true, + disabled: !vm.running, + action: () => vmAction(vm, 'restart'), + }); + } + + // Cluster actions + if (isKubeVirt) { + items.push({ separator: true }); + items.push({ + icon: 'migrate', + label: 'Migrate…', + mutate: true, + disabled: !(vm.ready && vm.liveMigratable), + title: vm.liveMigratable ? 'Live-migrate to another node' : 'Not live-migratable (persistent RWO disk)', + action: () => migrateVM(vm), + }); + + if (capability.snapshots) { + items.push({ + icon: 'camera', + label: 'Take snapshot', + mutate: true, + action: async () => { + try { + await post(vm, '/snapshots', {}); + toast('Snapshot started'); + refresh(true); + } catch (e) { + toast(e.message); + } + }, + }); + } + + items.push({ + icon: 'clone', + label: 'Clone…', + mutate: true, + action: () => cloneVM(vm), + }); + + items.push({ + icon: 'template', + label: vm.isTemplate ? 'Unmark template' : 'Convert to template', + mutate: true, + action: () => vmAction(vm, 'template'), + }); + + items.push({ + icon: 'plus', + label: 'Add tag…', + mutate: true, + action: () => { + const t = prompt('Add tag (letters, digits, -_.):', ''); + if (t && t.trim()) setTag(vm, t.trim(), true); + }, + }); + } + + // Drag-equivalent actions: Assign to Pool and Move to Backend + items.push({ separator: true }); + + items.push({ + icon: 'folder', + label: 'Assign to pool…', + mutate: true, + action: async () => { + const state = poolState(); + const paths = (state.folders || []).map((f) => f.path); + const promptMsg = paths.length + ? `Assign ${vm.name} to pool (leave empty to unassign):\nAvailable pools: ${paths.join(', ')}` + : `Assign ${vm.name} to pool path (leave empty to unassign):`; + const chosen = prompt(promptMsg, ''); + if (chosen === null) return; + const ref = vm.id || vmKey(vm); + try { + if (!chosen.trim()) { + await api(`/api/folders/members?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' }); + toast('Removed from its pool'); + } else { + await api('/api/folders/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: chosen.trim(), ref }), + }); + toast(`Added to ${chosen.trim()}`); + } + await loadPools(); + refresh(true); + } catch (err) { + toast(err.message); + } + }, + }); + + items.push({ + icon: 'server', + label: 'Move to backend…', + mutate: true, + action: async () => { + let dests = []; + try { dests = (await api('/api/move/destinations')).destinations || []; } catch {} + const available = dests.filter((d) => d.can && d.backend !== vm.backend).map((d) => d.backend); + if (!available.length) { + toast('No destination backend available for move.'); + return; + } + const chosen = prompt(`Move ${vm.name} to which backend? (${available.join(', ')})`, available[0]); + if (!chosen || !chosen.trim()) return; + const ref = vm.id || vmKey(vm); + let plan; + try { + plan = await api('/api/move/preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ref, toBackend: chosen.trim() }), + }); + } catch (e) { + toast(`Could not plan move: ${e.message}`); + return; + } + showMoveDialog(ref, chosen.trim(), plan); + }, + }); + + if (isKubeVirt) { + items.push({ + icon: 'download', + label: 'Export…', + mutate: true, + disabled: !!vm.running, + title: vm.running ? 'Stop the VM to export its disk' : 'Download a disk backup', + action: () => exportVM(vm), + }); + } + + // Delete action + if (capability.delete !== false) { + items.push({ separator: true }); + items.push({ + icon: 'trash', + label: 'Delete', + danger: true, + mutate: true, + action: () => vmAction(vm, 'delete'), + }); + } + + return items; +} + +function ctMenuItems(c) { + const running = c.phase === 'Running'; + return [ + { + icon: 'terminal', + label: 'Terminal', + action: () => { + select({ type: 'ct', key: ctKey(c) }); + ctTab = 'terminal'; + renderContent(); + markRendered(); + }, + }, + { separator: true }, + { + icon: 'play', + label: 'Start', + mutate: true, + disabled: running, + action: () => ctAction(c, 'start'), + }, + { + icon: 'stop', + label: 'Stop', + mutate: true, + disabled: !running, + action: () => ctAction(c, 'stop'), + }, + { separator: true }, + { + icon: 'trash', + label: 'Delete', + danger: true, + mutate: true, + action: () => ctAction(c, 'delete'), + }, + ]; +} + +function nodeMenuItems(nodeName) { + const items = [ + { + icon: 'server', + label: 'View node', + action: () => select({ type: 'node', name: nodeName }), + }, + ]; + + const h = (hostPower.hosts || []).find((x) => x.node === nodeName || x.name === nodeName); + if (h) { + items.push({ separator: true }); + if ((h.actions || []).includes('start')) { + items.push({ + icon: 'play', + label: 'Power on', + mutate: true, + disabled: h.state === 'running', + action: async () => { + try { + await api(`/api/hostpower/${encodeURIComponent(h.plugin)}/start?id=${encodeURIComponent(h.id)}`, { method: 'POST' }); + toast(`Powering on ${h.name}`); + refresh(true); + } catch (e) { toast(e.message); } + }, + }); + } + if ((h.actions || []).includes('stop')) { + items.push({ + icon: 'stop', + label: 'Power off', + mutate: true, + disabled: h.state === 'stopped', + action: async () => { + const onNode = h.node ? vms.filter((v) => v.node === h.node) : []; + if (onNode.length && !confirm(`Power off ${h.name}? ${onNode.length} VM(s) on it will stop.`)) return; + try { + await api(`/api/hostpower/${encodeURIComponent(h.plugin)}/stop?id=${encodeURIComponent(h.id)}`, { method: 'POST' }); + toast(`Powering off ${h.name}`); + refresh(true); + } catch (e) { toast(e.message); } + }, + }); + } + } + + items.push({ separator: true }); + items.push({ + icon: 'pause', + label: 'Cordon', + mutate: true, + disabled: true, + title: 'Cordon not supported on this backend', + action: () => {}, + }); + items.push({ + icon: 'migrate', + label: 'Drain', + mutate: true, + disabled: true, + title: 'Drain not supported on this backend', + action: () => {}, + }); + + return items; +} + +function hostPowerMenuItems(h) { + const items = [ + { + icon: 'server', + label: 'View host', + action: () => select({ type: 'hostpower', key: hostPowerKey(h) }), + }, + ]; + items.push({ separator: true }); + if ((h.actions || []).includes('start')) { + items.push({ + icon: 'play', + label: 'Power on', + mutate: true, + disabled: h.state === 'running', + action: async () => { + try { + await api(`/api/hostpower/${encodeURIComponent(h.plugin)}/start?id=${encodeURIComponent(h.id)}`, { method: 'POST' }); + toast(`Powering on ${h.name}`); + refresh(true); + } catch (e) { toast(e.message); } + }, + }); + } + if ((h.actions || []).includes('stop')) { + items.push({ + icon: 'stop', + label: 'Power off', + mutate: true, + disabled: h.state === 'stopped', + action: async () => { + const onNode = h.node ? vms.filter((v) => v.node === h.node) : []; + if (onNode.length && !confirm(`Power off ${h.name}? ${onNode.length} VM(s) on it will stop.`)) return; + try { + await api(`/api/hostpower/${encodeURIComponent(h.plugin)}/stop?id=${encodeURIComponent(h.id)}`, { method: 'POST' }); + toast(`Powering off ${h.name}`); + refresh(true); + } catch (e) { toast(e.message); } + }, + }); + } + return items; +} + +function namespaceMenuItems(ns) { + const nsVMs = vms.filter((v) => (v.namespace || '(none)') === ns); + const running = nsVMs.filter((v) => v.running); + const stopped = nsVMs.filter((v) => !v.running); + + return [ + { + icon: 'folder', + label: 'View namespace', + action: () => select({ type: 'namespace', name: ns }), + }, + { separator: true }, + { + icon: 'play', + label: 'Start all VMs', + mutate: true, + disabled: stopped.length === 0, + action: async () => { + if (!confirm(`Start ${stopped.length} stopped VM(s) in ${ns}?`)) return; + let ok = 0; let fail = 0; + await Promise.all(stopped.map(async (v) => { + try { await api(vmURL(v, '/start'), { method: 'POST' }); ok++; } + catch { fail++; } + })); + toast(`Start: ${ok} ok${fail ? `, ${fail} failed` : ''}`); + setTimeout(() => refresh(true), 800); + }, + }, + { + icon: 'stop', + label: 'Stop all VMs', + mutate: true, + disabled: running.length === 0, + action: async () => { + if (!confirm(`Stop ${running.length} running VM(s) in ${ns}?`)) return; + let ok = 0; let fail = 0; + await Promise.all(running.map(async (v) => { + try { await api(vmURL(v, '/stop'), { method: 'POST' }); ok++; } + catch { fail++; } + })); + toast(`Stop: ${ok} ok${fail ? `, ${fail} failed` : ''}`); + setTimeout(() => refresh(true), 800); + }, + }, + ]; +} + +function poolMenuItems(folder) { + const n = (folder.members || []).length; + return [ + { + icon: 'play', + label: 'Start all', + mutate: true, + disabled: n === 0, + action: async () => { + if (!confirm(`Start every instance in pool ${folder.path}? (${n} instances)`)) return; + try { + const res = await api('/api/folders/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: folder.path, action: 'start' }), + }); + toast(summariseOutcomes('start', res.members || [])); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + { + icon: 'stop', + label: 'Stop all', + mutate: true, + disabled: n === 0, + action: async () => { + if (!confirm(`Stop every instance in pool ${folder.path}? (${n} instances)`)) return; + try { + const res = await api('/api/folders/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: folder.path, action: 'stop' }), + }); + toast(summariseOutcomes('stop', res.members || [])); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + { + icon: 'restart', + label: 'Restart all', + mutate: true, + disabled: n === 0, + action: async () => { + if (!confirm(`Restart every instance in pool ${folder.path}? (${n} instances)`)) return; + try { + const res = await api('/api/folders/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: folder.path, action: 'restart' }), + }); + toast(summariseOutcomes('restart', res.members || [])); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + { separator: true }, + { + icon: 'plus', + label: 'New subpool…', + mutate: true, + action: async () => { + const p = prompt(`New subpool under ${folder.path} (e.g. ${folder.path}/sub):`, `${folder.path}/`); + if (!p) return; + try { + await api('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: p.trim() }), + }); + await loadPools(); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + { + icon: 'trash', + label: 'Delete pool', + danger: true, + mutate: true, + action: async () => { + if (!confirm(`Delete pool ${folder.path}? Members will be unfoldered, not deleted.`)) return; + try { + await api(`/api/folders?path=${encodeURIComponent(folder.path)}`, { method: 'DELETE' }); + toast('Pool deleted'); + await loadPools(); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + ]; +} + +function unassignedMenuItems() { + return [ + { + icon: 'plus', + label: 'New pool…', + mutate: true, + action: async () => { + const path = prompt('Pool path (nest with /, e.g. prod/web):'); + if (!path) return; + try { + await api('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + await loadPools(); + refresh(true); + } catch (err) { toast(err.message); } + }, + }, + ]; +} + function treeRow({ lvl, icon, label, sub, sel, onclick, dot }) { const div = document.createElement('div'); div.className = `tree-item lvl-${lvl}${sel ? ' selected' : ''}`; + div.setAttribute('tabindex', '0'); // The label is its own element so it can be ellipsized: a bare text node is // an anonymous flex item and refuses to shrink, which is how a long VM name // used to push the row past the sidebar edge (#290). @@ -297,6 +1013,14 @@ function treeRow({ lvl, icon, label, sub, sel, onclick, dot }) { ` ${esc(label)}` + (sub ? ` ${esc(sub)}` : ''); div.onclick = () => { onclick(); closeDrawer(); }; + div.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (e.target === div || !e.target.closest('button')) { + e.preventDefault(); + div.click(); + } + } + }); return div; } @@ -354,48 +1078,60 @@ function renderTree() { if (!filter.isConnected) tree.appendChild(filter); tree.appendChild(treeViewToggle()); - tree.appendChild(treeRow({ + const dcRow = treeRow({ lvl: 0, icon: icon('datacenter'), label: 'Datacenter', sel: selected.type === 'dc', onclick: () => select({ type: 'dc' }), - })); + }); + attachContextMenu(dcRow, () => [{ icon: 'datacenter', label: 'Open Datacenter', action: () => select({ type: 'dc' }) }]); + tree.appendChild(dcRow); - tree.appendChild(treeRow({ + const docRow = treeRow({ lvl: 0, icon: icon('health'), label: 'Cluster health', sel: selected.type === 'doctor', onclick: () => select({ type: 'doctor' }), - })); + }); + attachContextMenu(docRow, () => [{ icon: 'health', label: 'Open Cluster health', action: () => select({ type: 'doctor' }) }]); + tree.appendChild(docRow); - tree.appendChild(treeRow({ + const extRow = treeRow({ lvl: 0, icon: icon('extension'), label: 'Extensions', sel: selected.type === 'extensions', onclick: () => select({ type: 'extensions' }), - })); + }); + attachContextMenu(extRow, () => [{ icon: 'extension', label: 'Open Extensions', action: () => select({ type: 'extensions' }) }]); + tree.appendChild(extRow); - tree.appendChild(treeRow({ + const mvRow = treeRow({ lvl: 0, icon: icon('cube'), label: 'Multiview', sub: 'live consoles', sel: selected.type === 'multiview', onclick: () => select({ type: 'multiview' }), - })); + }); + attachContextMenu(mvRow, () => [{ icon: 'cube', label: 'Open Multiview', action: () => select({ type: 'multiview' }) }]); + tree.appendChild(mvRow); - tree.appendChild(treeRow({ + const setRow = treeRow({ lvl: 0, icon: icon('cog'), label: 'Settings', sub: 'theme & branding', sel: selected.type === 'settings', onclick: () => select({ type: 'settings' }), - })); + }); + attachContextMenu(setRow, () => [{ icon: 'cog', label: 'Open Settings', action: () => select({ type: 'settings' }) }]); + tree.appendChild(setRow); // Hosts that a host-power plugin can switch on and off (e.g. an on-demand // cloud VM node kept stopped when idle). Shown only when a plugin reports any. for (const h of hostPower.hosts || []) { - tree.appendChild(treeRow({ + const hpRow = treeRow({ lvl: 0, icon: icon('server'), label: h.name, sub: h.state, dot: hostPowerDot(h.state), sel: selected.type === 'hostpower' && selected.key === hostPowerKey(h), onclick: () => select({ type: 'hostpower', key: hostPowerKey(h) }), - })); + }); + attachContextMenu(hpRow, () => hostPowerMenuItems(h)); + tree.appendChild(hpRow); } if (treeView === 'pool') renderTreePools(tree); @@ -416,6 +1152,7 @@ function ctRow(c, lvl) { onclick: () => select({ type: 'ct', key: ctKey(c) }), }); row.dataset.guest = c.name; + attachContextMenu(row, () => ctMenuItems(c)); return row; } @@ -428,12 +1165,14 @@ function renderTreeServer(tree) { const ctPlaced = new Set(); for (const n of nodes) { - tree.appendChild(treeRow({ + const row = treeRow({ lvl: 1, icon: icon('server'), label: n.name, sub: n.roles, dot: n.ready ? 'on' : 'off', sel: selected.type === 'node' && selected.name === n.name, onclick: () => select({ type: 'node', name: n.name }), - })); + }); + attachContextMenu(row, () => nodeMenuItems(n.name)); + tree.appendChild(row); for (const vm of byNode(n.name)) { placed.add(vmKey(vm)); tree.appendChild(vmRow(vm, 2)); @@ -476,11 +1215,13 @@ function renderTreeNamespaces(tree) { const parts = []; if (nsVMs.length) parts.push(`${nsVMs.length} VM${nsVMs.length === 1 ? '' : 's'}`); if (nsCTs.length) parts.push(`${nsCTs.length} CT${nsCTs.length === 1 ? '' : 's'}`); - tree.appendChild(treeRow({ + const row = treeRow({ lvl: 1, icon: icon('folder'), label: ns, sub: parts.join(', '), sel: selected.type === 'namespace' && selected.name === ns, onclick: () => select({ type: 'namespace', name: ns }), - })); + }); + attachContextMenu(row, () => namespaceMenuItems(ns)); + tree.appendChild(row); for (const vm of nsVMs) tree.appendChild(vmRow(vm, 2)); for (const c of nsCTs) tree.appendChild(ctRow(c, 2)); } @@ -506,6 +1247,7 @@ function vmRow(vm, lvl) { }; row.prepend(check); row.dataset.guest = vm.name; + attachContextMenu(row, () => vmMenuItems(vm)); return row; } @@ -1137,7 +1879,17 @@ function ctTable(list) { function bindCTTable(root) { root.querySelectorAll('tr[data-ctkey]').forEach((tr) => { + const c = findCT(tr.dataset.ctkey); + if (c) attachContextMenu(tr, () => ctMenuItems(c)); tr.onclick = () => select({ type: 'ct', key: tr.dataset.ctkey }); + tr.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (!e.target.closest('button')) { + e.preventDefault(); + select({ type: 'ct', key: tr.dataset.ctkey }); + } + } + }); }); } @@ -1276,6 +2028,7 @@ function bindVMTable(root, list) { selected: selectedVMKeys, checkClass: 'vm-check', checkAllClass: 'vm-check-all', onRowClick: (vm) => select({ type: 'vm', key: vmKey(vm) }), onSelectionChange: () => { update(); renderTree(); }, + decorateRow: (tr, vm) => attachContextMenu(tr, () => vmMenuItems(vm)), }); bar.querySelectorAll('[data-bulk]').forEach((b) => { @@ -1327,10 +2080,20 @@ function bindTemplateTable(root) { }); // Scoped to the template table: the inventory grid binds its own rows. root.querySelectorAll('.template-table tr[data-key]').forEach((tr) => { + const vm = findVM(tr.dataset.key); + if (vm) attachContextMenu(tr, () => vmMenuItems(vm)); tr.onclick = (e) => { if (e.target.closest('button')) return; select({ type: 'vm', key: tr.dataset.key }); }; + tr.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + if (!e.target.closest('button')) { + e.preventDefault(); + select({ type: 'vm', key: tr.dataset.key }); + } + } + }); }); } @@ -2972,7 +3735,10 @@ $('#btn-create').innerHTML = `${icon('plus')} Create VM`; // Pool View borrows the tree's row builders rather than growing its own, so a // pool row and a node row stay visually identical — the difference is what a // drop onto one means, not how it looks. -bindPools({ api, toast, esc, icon, refresh, treeRow, vmRow }); +bindPools({ + api, toast, esc, icon, refresh, treeRow, vmRow, + attachContextMenu, poolMenuItems, unassignedMenuItems, +}); // The palette reads the fleet through getters: the poll replaces these arrays // rather than mutating them, so a captured reference would go stale. diff --git a/pkg/web/static/grid.js b/pkg/web/static/grid.js index 7a39e987..c003b07f 100644 --- a/pkg/web/static/grid.js +++ b/pkg/web/static/grid.js @@ -222,6 +222,7 @@ export function mountGrid(host, options) { tr.onclick = (event) => { if (!event.target.closest('.check')) onRowClick?.(row); }; tr.onkeydown = (event) => { if (event.key === 'Enter') onRowClick?.(row); }; const checkCell = document.createElement('td'); checkCell.className = 'check'; const check = document.createElement('input'); check.type = 'checkbox'; check.className = options.checkClass || 'grid-check'; check.checked = selected.has(rowKey(row)); check.setAttribute('aria-label', `Select ${rowKey(row)}`); check.onchange = () => { check.checked ? selected.add(rowKey(row)) : selected.delete(rowKey(row)); onSelectionChange?.(selected); syncSelection(); }; checkCell.appendChild(check); tr.appendChild(checkCell); + options.decorateRow?.(tr, row); cols.forEach((col) => { const td = document.createElement('td'); const rendered = col.render?.(row); if (rendered instanceof Node) td.appendChild(rendered); else td.textContent = rendered ?? valueFor(row, col) ?? ''; tr.appendChild(td); }); body.appendChild(tr); }); diff --git a/pkg/web/static/pools.js b/pkg/web/static/pools.js index 19b6a568..a579109a 100644 --- a/pkg/web/static/pools.js +++ b/pkg/web/static/pools.js @@ -53,6 +53,7 @@ export function renderTreePools(tree) { onclick: () => {}, }); header.appendChild(newPoolButton()); + if (ctx.attachContextMenu && ctx.unassignedMenuItems) ctx.attachContextMenu(header, () => ctx.unassignedMenuItems()); tree.appendChild(header); for (const folder of pools.folders) { @@ -66,6 +67,7 @@ export function renderTreePools(tree) { row.title = folder.path; dropTargetPool(row, folder.path); row.appendChild(poolActions(folder)); + if (ctx.attachContextMenu && ctx.poolMenuItems) ctx.attachContextMenu(row, () => ctx.poolMenuItems(folder)); tree.appendChild(row); for (const vm of folder.members || []) { @@ -96,6 +98,7 @@ export function renderTreePools(tree) { onclick: () => {}, }); dropTargetPool(loose, ''); + if (ctx.attachContextMenu && ctx.unassignedMenuItems) ctx.attachContextMenu(loose, () => ctx.unassignedMenuItems()); tree.appendChild(loose); for (const vm of unassigned) { const row = vmRow(vm, 1); diff --git a/pkg/web/static/style.css b/pkg/web/static/style.css index f2ccd80a..3123efca 100644 --- a/pkg/web/static/style.css +++ b/pkg/web/static/style.css @@ -183,7 +183,8 @@ body.read-only #btn-create, body.read-only #btn-create-ct, body.read-only .toolbar, body.read-only .hp-action, -body.read-only .bulkbar button[data-bulk] { display: none !important; } +body.read-only .bulkbar button[data-bulk], +body.read-only .menu-mutate { display: none !important; } .btn { display: inline-flex; @@ -829,3 +830,103 @@ kbd { } #shortcuts dt { color: var(--muted); font-size: .8rem; } #shortcuts dd { margin: 0; } + +/* ── Context Menus & Keyboard Focus ──────────────────────────────── */ + +.tree-item:focus-visible, +tbody tr:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.context-menu { + position: fixed; + z-index: 1000; + min-width: 180px; + max-width: 280px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + outline: none; + user-select: none; + animation: context-menu-in 0.08s ease-out; +} + +@keyframes context-menu-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +.menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + font-size: 0.85rem; + color: var(--text); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + text-align: left; + white-space: nowrap; + outline: none; +} + +.menu-item .icon { + width: 15px; + height: 15px; + color: var(--muted); + flex-shrink: 0; +} + +.menu-item .menu-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.menu-item:hover, +.menu-item:focus, +.menu-item:focus-visible { + background: var(--panel-2); + color: var(--text); +} + +.menu-item:hover .icon, +.menu-item:focus .icon, +.menu-item:focus-visible .icon { + color: var(--accent); +} + +.menu-item.danger:hover, +.menu-item.danger:focus, +.menu-item.danger:focus-visible { + background: rgba(248, 81, 73, 0.15); + color: var(--red); +} + +.menu-item.danger:hover .icon, +.menu-item.danger:focus .icon, +.menu-item.danger:focus-visible .icon { + color: var(--red); +} + +.menu-item:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +.menu-separator { + height: 1px; + background: var(--border); + margin: 3px 0; +} + diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index c8d84869..38911bd6 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -101,6 +101,51 @@ check( `${wasRunning ? 'stop' : 'start'} action flips VM state`, ); +// ── Context Menu (context-menu acceptance check) ─────────────────── +// Right-click a demo VM, choose Stop (or Start), and assert that the state changes. +await page.click('#tree >> text=Datacenter'); +await page.waitForTimeout(800); +const demoRow = page.locator('tr[data-key*="web-prod"]').first(); +await demoRow.click({ button: 'right' }); +await page.waitForSelector('.context-menu', { timeout: 10000 }); +check(await page.locator('.context-menu').count() > 0, 'context-menu: right-click opens action menu'); + +// Save a screenshot with the context menu open +await page.screenshot({ path: 'context-menu.png' }).catch(() => {}); + +// Context menu closes on Escape +await page.keyboard.press('Escape'); +await page.waitForTimeout(300); +check(await page.locator('.context-menu').count() === 0, 'context-menu: Escape closes action menu'); + +// Right-click again to drive Stop (or Start) action and verify state flips +await demoRow.click({ button: 'right' }); +await page.waitForSelector('.context-menu', { timeout: 10000 }); +const stopItem = page.locator('.context-menu button.menu-item:has-text("Stop")'); +const startItem = page.locator('.context-menu button.menu-item:has-text("Start")'); +const canStop = (await stopItem.count()) > 0 && !(await stopItem.getAttribute('disabled')); + +if (canStop) { + await stopItem.click(); + await page.waitForTimeout(5500); + const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); + check(statusCell.includes('Stopped'), 'context-menu: Stop action flips VM state'); +} else if ((await startItem.count()) > 0 && !(await startItem.getAttribute('disabled'))) { + await startItem.click(); + await page.waitForTimeout(5500); + const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); + check(statusCell.includes('Running'), 'context-menu: Start action flips VM state'); +} + +// Tree row right-click +const treeDemoRow = page.locator('#tree .tree-item', { hasText: 'web-prod' }).first(); +if (await treeDemoRow.count() > 0) { + await treeDemoRow.click({ button: 'right' }); + await page.waitForSelector('.context-menu', { timeout: 5000 }); + check(await page.locator('.context-menu').count() > 0, 'context-menu: tree row right-click opens action menu'); + await page.keyboard.press('Escape'); +} + // Cluster health is green in demo. await page.click('#tree >> text=Cluster health'); await page.waitForTimeout(2500); From b77e579be961607794f200a8e58fdeb8a8e31a6a Mon Sep 17 00:00:00 2001 From: hanthor Date: Fri, 2 Oct 2026 00:08:06 +0000 Subject: [PATCH 4/7] fix(web): keep context menus open across polls; fix smoke item lookup A poll re-render replaced the rows under an open menu. The smoke check read disabled="" as enabled and tried to click a disabled Stop item, and its has-text("Start") also matched Restart. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexZnEN3jq4jRUi8T3zZuY Signed-off-by: hanthor --- pkg/web/static/app.js | 3 +++ scripts/ui-smoke.mjs | 16 ++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index 64b06621..ed7cba61 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -194,6 +194,9 @@ async function refresh(force = false) { } const fp = JSON.stringify([vms, cts, nodes, hostPower, selected, tab]); if (!force && fp === lastRenderFp) return; // nothing changed — keep the DOM + // A poll must not pull the rows out from under an open context menu; the + // next tick after it closes renders the change. + if (!force && activeContextMenu) return; lastRenderFp = fp; // Re-render, preserving scroll positions across the DOM swap. diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index 38911bd6..14646b6f 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -121,17 +121,21 @@ check(await page.locator('.context-menu').count() === 0, 'context-menu: Escape c // Right-click again to drive Stop (or Start) action and verify state flips await demoRow.click({ button: 'right' }); await page.waitForSelector('.context-menu', { timeout: 10000 }); -const stopItem = page.locator('.context-menu button.menu-item:has-text("Stop")'); -const startItem = page.locator('.context-menu button.menu-item:has-text("Start")'); -const canStop = (await stopItem.count()) > 0 && !(await stopItem.getAttribute('disabled')); +// Exact labels: "Start" must not match "Restart". A disabled item carries +// disabled="" — an empty, falsy attribute — so ask for the enabled state. +const menuItem = (label) => page.locator('.context-menu button.menu-item') + .filter({ has: page.locator('.menu-label', { hasText: new RegExp(`^${label}$`) }) }); +const stopItem = menuItem('Stop'); +const startItem = menuItem('Start'); +const canStop = (await stopItem.count()) > 0 && await stopItem.first().isEnabled(); if (canStop) { - await stopItem.click(); + await stopItem.first().click(); await page.waitForTimeout(5500); const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); check(statusCell.includes('Stopped'), 'context-menu: Stop action flips VM state'); -} else if ((await startItem.count()) > 0 && !(await startItem.getAttribute('disabled'))) { - await startItem.click(); +} else if ((await startItem.count()) > 0 && await startItem.first().isEnabled()) { + await startItem.first().click(); await page.waitForTimeout(5500); const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); check(statusCell.includes('Running'), 'context-menu: Start action flips VM state'); From b9337185b38fa5ac624ed9df823c6cc434cda0c2 Mon Sep 17 00:00:00 2001 From: hanthor Date: Fri, 2 Oct 2026 00:41:53 +0000 Subject: [PATCH 5/7] fix(web): context menus on inventory grid rows; robust smoke status read Attach the VM menu to grid rows via a new mountGrid decorateRow hook, and read the row text in the smoke check because grid column order is user-configurable (the grid-columns check reorders it). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexZnEN3jq4jRUi8T3zZuY Signed-off-by: hanthor --- scripts/ui-smoke.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index 14646b6f..011711c2 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -132,12 +132,12 @@ const canStop = (await stopItem.count()) > 0 && await stopItem.first().isEnabled if (canStop) { await stopItem.first().click(); await page.waitForTimeout(5500); - const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); + const statusCell = await page.locator('tr[data-key*="web-prod"]').first().innerText(); // column order is user-configurable check(statusCell.includes('Stopped'), 'context-menu: Stop action flips VM state'); } else if ((await startItem.count()) > 0 && await startItem.first().isEnabled()) { await startItem.first().click(); await page.waitForTimeout(5500); - const statusCell = await page.locator('tr[data-key*="web-prod"] td').nth(2).textContent(); + const statusCell = await page.locator('tr[data-key*="web-prod"]').first().innerText(); // column order is user-configurable check(statusCell.includes('Running'), 'context-menu: Start action flips VM state'); } From dfa56a09888ad5d505421baedc9160d774e6c570 Mon Sep 17 00:00:00 2001 From: hanthor Date: Fri, 2 Oct 2026 00:45:27 +0000 Subject: [PATCH 6/7] feat(web): tree range selection, tag/IP filter, arrow-key focus and more bulk actions Rebuilds #374 on top of the command palette (#369), inventory grid (#373) and context menus (#385), which already cover the tree filter box, the shared tree/grid selection set and the pool/backend menu equivalents. What this adds: - Shift-click selects the visible range from the anchor, Ctrl/Cmd-click toggles; selected rows are marked and the grid follows. - The tree filter also matches tags and IP addresses. - Arrow/Home/End move focus between visible tree rows. - Bulk Snapshot, Tag and Delete next to Start/Stop/Restart. - A bulk-select smoke check. Fixes #344 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexZnEN3jq4jRUi8T3zZuY Signed-off-by: hanthor --- pkg/web/static/app.js | 76 ++++++++++++++++++++++++++++++++++++---- pkg/web/static/style.css | 3 ++ scripts/ui-smoke.mjs | 50 ++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index ed7cba61..384cec8f 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -1015,8 +1015,16 @@ function treeRow({ lvl, icon, label, sub, sel, onclick, dot }) { div.innerHTML = `${dot ? `` : ''}${icon}` + ` ${esc(label)}` + (sub ? ` ${esc(sub)}` : ''); - div.onclick = () => { onclick(); closeDrawer(); }; + div.onclick = (e) => { onclick(e); closeDrawer(); }; div.addEventListener('keydown', (e) => { + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key) && e.target === div) { + // Roving focus over the rows the filter leaves visible. + const rows = [...document.querySelectorAll('#tree .tree-item')].filter((r) => !r.hidden && r.offsetParent !== null); + const at = rows.indexOf(div); + const to = e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : at + (e.key === 'ArrowDown' ? 1 : -1); + if (rows[to]) { e.preventDefault(); rows[to].focus(); } + return; + } if (e.key === 'Enter' || e.key === ' ') { if (e.target === div || !e.target.closest('button')) { e.preventDefault(); @@ -1062,7 +1070,7 @@ function treeFilterBox() { function applyTreeFilter() { $('#tree').querySelectorAll('.tree-item[data-guest]').forEach((row) => { - row.hidden = !!treeFilter && !row.dataset.guest.toLowerCase().includes(treeFilter); + row.hidden = !!treeFilter && !(row.dataset.search || row.dataset.guest.toLowerCase()).includes(treeFilter); }); } @@ -1236,8 +1244,12 @@ function vmRow(vm, lvl) { sub: vm.isTemplate ? 'template' : vm.namespace, dot: vm.ready ? 'on' : (vm.running ? 'mid' : 'off'), sel: selected.type === 'vm' && selected.key === vmKey(vm), - onclick: () => select({ type: 'vm', key: vmKey(vm) }), + onclick: (e) => treeVMClick(vmKey(vm), e), }); + row.dataset.vmKey = vmKey(vm); + row.dataset.search = [vm.name, vm.ip, ...(vm.tags || [])].filter(Boolean).join(' ').toLowerCase(); + row.classList.toggle('multi-selected', selectedVMKeys.has(vmKey(vm))); + row.setAttribute('aria-selected', selectedVMKeys.has(vmKey(vm)) ? 'true' : 'false'); const check = document.createElement('input'); check.type = 'checkbox'; check.className = 'tree-vm-check'; @@ -1246,6 +1258,8 @@ function vmRow(vm, lvl) { check.onclick = (event) => event.stopPropagation(); check.onchange = () => { check.checked ? selectedVMKeys.add(vmKey(vm)) : selectedVMKeys.delete(vmKey(vm)); + selectionAnchorKey = vmKey(vm); + row.classList.toggle('multi-selected', check.checked); renderContent(); }; row.prepend(check); @@ -1256,6 +1270,35 @@ function vmRow(vm, lvl) { // markRendered records the just-rendered state so the next poll tick doesn't // re-render (and reset scroll) for a change the user already saw. +// Tree selection: a plain click opens the VM and sets the range anchor; +// Ctrl/Cmd toggles one VM and Shift selects the visible range from the anchor. +// The set is the one the inventory grid uses, so both stay in step. +let selectionAnchorKey = null; + +function treeVMClick(key, e) { + if (e?.shiftKey && selectionAnchorKey) { + const keys = [...document.querySelectorAll('#tree .tree-item[data-vm-key]')] + .filter((r) => !r.hidden).map((r) => r.dataset.vmKey); + const a = keys.indexOf(selectionAnchorKey); + const b = keys.indexOf(key); + if (a >= 0 && b >= 0) { + selectedVMKeys.clear(); + keys.slice(Math.min(a, b), Math.max(a, b) + 1).forEach((k) => selectedVMKeys.add(k)); + } + } else if (e?.ctrlKey || e?.metaKey) { + if (selectedVMKeys.has(key)) selectedVMKeys.delete(key); + else selectedVMKeys.add(key); + selectionAnchorKey = key; + } else { + selectionAnchorKey = key; + select({ type: 'vm', key }); + return; + } + renderTree(); + renderContent(); + markRendered(); +} + function markRendered() { lastRenderFp = JSON.stringify([vms, cts, nodes, selected, tab]); } @@ -1987,6 +2030,9 @@ function vmTable(list) { + + +
    `; } @@ -2040,13 +2086,29 @@ function bindVMTable(root, list) { const act = b.dataset.bulk; const sel = selectedKeys().map(findVM).filter(Boolean); if (!sel.length) return; - const verb = act === 'start' ? 'Start' : act === 'stop' ? 'Stop' : 'Restart'; - if (!confirm(`${verb} ${sel.length} VM${sel.length === 1 ? '' : 's'}?`)) return; + const verb = { start: 'Start', stop: 'Stop', restart: 'Restart', snapshot: 'Snapshot', tag: 'Tag', delete: 'Delete' }[act]; + const plural = `${sel.length} VM${sel.length === 1 ? '' : 's'}`; + let tag = ''; + if (act === 'tag') { + tag = (prompt(`Tag ${plural} with:`, '') || '').trim(); + if (!tag) return; + } else if (act === 'delete') { + if (!confirm(`Delete ${plural} and their disks?\n\n${sel.map((v) => v.name).join('\n')}`)) return; + } else if (!confirm(`${verb} ${plural}?`)) return; let ok = 0; let fail = 0; await Promise.all(sel.map(async (vm) => { - try { await api(vmURL(vm, `/${act}`), { method: 'POST' }); ok += 1; } - catch { fail += 1; } + try { + if (act === 'snapshot') await post(vm, '/snapshots', {}); + else if (act === 'tag') await post(vm, '/tags', { tag, on: true }); + else if (act === 'delete') { + let target = vmURL(vm); + if (vm.backend === 'libvirt') target += `${target.includes('?') ? '&' : '?'}destroyStorage=true`; + await api(target, { method: 'DELETE' }); + selectedVMKeys.delete(vmKey(vm)); + } else await api(vmURL(vm, `/${act}`), { method: 'POST' }); + ok += 1; + } catch { fail += 1; } })); toast(`${verb}: ${ok} ok${fail ? `, ${fail} failed` : ''}`); setTimeout(() => refresh(), 800); diff --git a/pkg/web/static/style.css b/pkg/web/static/style.css index 3123efca..3e95f15c 100644 --- a/pkg/web/static/style.css +++ b/pkg/web/static/style.css @@ -930,3 +930,6 @@ tbody tr:focus-visible { margin: 3px 0; } + +/* Tree multi-select (#344): rows in the shared VM selection. */ +.tree-item.multi-selected { background: color-mix(in srgb, var(--accent) 16%, transparent); } diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index 011711c2..80b52039 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -384,6 +384,56 @@ check( '/metrics always reports whether collection is working', ); +// ── bulk-select (#344) ───────────────────────────────────────────── +// The tree filter matches tags and IPs as well as names, tree rows support +// arrow-key focus, and Shift-click selects a range that the inventory grid +// shares and acts on in bulk. +await page.goto(BASE); +await page.waitForSelector('#tree [data-vm-key]', { timeout: 30000 }).catch(() => {}); +await page.click('#tree >> text=Server View'); +await page.waitForTimeout(800); +await page.evaluate(() => document.activeElement?.blur()); +await page.keyboard.press('/'); +await page.keyboard.type('prod'); +check(await page.locator('#tree [data-vm-key]:visible').count() >= 2, 'tree filter matches VM tags'); +const anIP = (await (await fetch(`${BASE}api/vms`)).json()).find((v) => v.ip && v.backend === 'kubevirt')?.ip || ''; +await page.locator('#tree-filter').fill(anIP); +check(!!anIP && await page.locator('#tree [data-vm-key]:visible').count() === 1, `tree filter matches VM IPs (${anIP})`); +await page.locator('#tree-filter').fill(''); +await page.locator('#tree-filter').dispatchEvent('input'); +const webTreeRow = page.locator('#tree [data-vm-key]', { hasText: 'web-prod' }).first(); +const nextRowText = await webTreeRow.evaluate((row) => { + const rows = [...document.querySelectorAll('#tree .tree-item')].filter((r) => !r.hidden && r.offsetParent !== null); + return rows[rows.indexOf(row) + 1]?.textContent ?? ''; +}); +await webTreeRow.focus(); +await page.keyboard.press('ArrowDown'); +check( + nextRowText !== '' && (await page.locator('#tree .tree-item:focus').textContent().catch(() => '')) === nextRowText, + 'tree ArrowDown moves focus to the next row', +); +const treeVMs = await page.locator('#tree [data-vm-key] .tree-label').allTextContents(); +const webAt = treeVMs.indexOf('web-prod'); +const range = treeVMs.slice(webAt, webAt + 3); +await webTreeRow.click(); +await page.locator('#tree [data-vm-key]', { hasText: range[2] }).first().click({ modifiers: ['Shift'] }); +check(await page.locator('#tree [data-vm-key].multi-selected').count() === 3, `bulk-select: Shift selects three tree VMs (${range.join(', ')})`); +await page.locator('#tree .tree-item', { hasText: 'Datacenter' }).first().click(); +await page.waitForSelector('#content .vm-check', { timeout: 10000 }).catch(() => {}); +check(await page.locator('#content .vm-check:checked').count() === 3, 'bulk-select: tree selection is shared with the grid'); +page.once('dialog', (dialog) => dialog.accept()); +await page.locator('#content .bulkbar [data-bulk="stop"]').click(); +await page.waitForFunction(async (names) => { + const fleet = await (await fetch('/api/vms')).json(); + return names.every((name) => fleet.find((vm) => vm.name === name)?.status?.includes('Stopped')); +}, range, { timeout: 10000 }).catch(() => {}); +const stoppedFleet = await (await fetch(`${BASE}api/vms`)).json(); +check( + range.every((name) => stoppedFleet.find((vm) => vm.name === name)?.status?.includes('Stopped')), + 'bulk-select: bulk Stop stops all three selected VMs', +); +await page.screenshot({ path: `${SHOTS}/bulk-select.png` }); + check(pageErrors.length === 0, `no JS page errors (${pageErrors.join('; ').slice(0, 200)})`); await browser.close(); From 5b63fedd737bd35566a6076f524017640101b0c5 Mon Sep 17 00:00:00 2001 From: hanthor Date: Fri, 2 Oct 2026 00:45:06 +0000 Subject: [PATCH 7/7] feat(web): drag a VM onto a node to migrate it Server View node rows become drop targets: dropping a VM opens the migrate picker with that node preselected and a live/offline note, and confirming runs the existing migrate endpoint. Drag and drop is unified in pools.js (makeDraggable/dropZone) with validity checks, so invalid drops (node not ready, already there, non-KubeVirt VM, pool it is already in, backend that cannot receive it) are refused with the reason as a tooltip and a red outline. Read-only mode disables dragging. The demo cluster now honours a migrate target: it records the nodeSelector patch, moves the VM on virtctl migrate, and answers get vmi, so the drag-migrate smoke check round-trips. Reworked from #384 on top of #385: menu entries come from #385, and the export picker, pickNode selectors and console pop-out are kept. Fixes #345 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TexZnEN3jq4jRUi8T3zZuY Signed-off-by: hanthor --- pkg/demo/demo.go | 70 +++++++++++++- pkg/demo/demo_test.go | 48 ++++++++++ pkg/web/static/app.js | 59 +++++++++--- pkg/web/static/pools.js | 194 +++++++++++++++++++++++++++++---------- pkg/web/static/style.css | 9 ++ scripts/ui-smoke.mjs | 29 ++++++ 6 files changed, 346 insertions(+), 63 deletions(-) diff --git a/pkg/demo/demo.go b/pkg/demo/demo.go index 16b942de..d09ad2e7 100644 --- a/pkg/demo/demo.go +++ b/pkg/demo/demo.go @@ -118,6 +118,7 @@ type demoVM struct { Template bool ExpiresAt string Load float64 // baseline CPU load in millicores for the sparkline + pinnedNode string } func (v *demoVM) running() bool { @@ -302,6 +303,36 @@ func (d *demoCluster) dispatch(stdin, name string, args []string) ([]byte, error case args[0] == "label" && len(args) >= 3: d.applyTagLabel(args) return []byte{}, nil + case len(args) >= 3 && args[0] == "patch" && args[1] == "vm": + v := d.find(args[2], flagValue(args, "-n")) + if v != nil { + for i, a := range args { + if a == "-p" && i+1 < len(args) { + var p struct { + Spec struct { + Template struct { + Spec struct { + NodeSelector map[string]string `json:"nodeSelector"` + } `json:"spec"` + } `json:"template"` + } `json:"spec"` + } + if json.Unmarshal([]byte(args[i+1]), &p) == nil && p.Spec.Template.Spec.NodeSelector != nil { + if target := p.Spec.Template.Spec.NodeSelector["kubernetes.io/hostname"]; target != "" { + v.Node = target + v.pinnedNode = target + } + } + } + } + } + return []byte{}, nil + case len(args) >= 3 && args[0] == "get" && (args[1] == "vmi" || args[1] == "vmis") && !strings.HasPrefix(args[2], "-"): + v := d.find(args[2], flagValue(args, "-n")) + if v == nil { + return nil, fmt.Errorf("virtualmachineinstance %q not found", args[2]) + } + return d.vmiJSON(v), nil case args[0] == "get" && hasJSONOutput(args): // A named get ("get datavolume foo -o json") must 404 like kubectl // would — returning a list shape parses as a zero object and callers @@ -352,7 +383,17 @@ func (d *demoCluster) virtctl(args []string) ([]byte, error) { case "restart": v.Status = "Running" case "migrate": - v.Status = "Migrating" + if v.pinnedNode != "" { + v.Node = v.pinnedNode + v.pinnedNode = "" + } else if v.Node == "" || v.Node == "corral-1" { + v.Node = "corral-2" + } else if v.Node == "corral-2" { + v.Node = "corral-3" + } else { + v.Node = "corral-1" + } + v.Status = "Running" } return []byte{}, nil } @@ -533,6 +574,27 @@ func (d *demoCluster) vmItem(v *demoVM) map[string]any { } } +func (d *demoCluster) vmiJSON(v *demoVM) []byte { + b, _ := json.Marshal(map[string]any{ + "metadata": map[string]any{"name": v.Name, "namespace": v.NS}, + "status": map[string]any{ + "nodeName": v.Node, + "phase": "Running", + "migrationState": map[string]any{ + "completed": true, + "sourceNode": "corral-1", + "targetNode": v.Node, + }, + "interfaces": []map[string]any{{"ipAddress": v.IP}}, + "conditions": []map[string]any{ + {"type": "LiveMigratable", "status": "True"}, + {"type": "AgentConnected", "status": "True"}, + }, + }, + }) + return b +} + func (d *demoCluster) vmiListJSON() []byte { items := []map[string]any{} for _, v := range d.vms { @@ -652,7 +714,11 @@ var demoStorageClassJSON = []byte(`{"items": [{ var demoNodesJSON = func() []byte { node := func(name, role string) map[string]any { - labels := map[string]string{"kubernetes.io/hostname": name} + labels := map[string]string{ + "kubernetes.io/hostname": name, + "kubevirt.io/schedulable": "true", + "cpu-vendor.node.kubevirt.io/intel": "true", + } if role != "" { labels["node-role.kubernetes.io/"+role] = "true" } diff --git a/pkg/demo/demo_test.go b/pkg/demo/demo_test.go index b2ea80d4..c376b82a 100644 --- a/pkg/demo/demo_test.go +++ b/pkg/demo/demo_test.go @@ -78,3 +78,51 @@ metadata: t.Fatal("expected test-new-vm to be deleted") } } + +func TestDemoCluster_MigrateAndPatch(t *testing.T) { + d := newDemoCluster() + v := d.find("web-prod", "corral-vms") + if v == nil { + t.Fatal("web-prod VM not found") + } + if v.Node != "corral-1" { + t.Fatalf("expected web-prod to be on corral-1 initially, got %s", v.Node) + } + + // 1. Pinned migration: patch nodeSelector, then run virtctl migrate + patchJSON := `{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"corral-2"}}}}}` + if _, err := d.dispatch("", "kubectl", []string{"patch", "vm", "web-prod", "-n", "corral-vms", "--type", "merge", "-p", patchJSON}); err != nil { + t.Fatalf("patch failed: %v", err) + } + if v.Node != "corral-2" { + t.Fatalf("expected web-prod node to be updated to corral-2 by patch, got %s", v.Node) + } + if _, err := d.virtctl([]string{"migrate", "web-prod", "-n", "corral-vms"}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + if v.Node != "corral-2" { + t.Fatalf("expected web-prod node to remain corral-2 after pinned migrate, got %s", v.Node) + } + + // 2. Unpinned migration: virtctl migrate cycles to another node + if _, err := d.virtctl([]string{"migrate", "web-prod", "-n", "corral-vms"}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + if v.Node != "corral-3" { + t.Fatalf("expected web-prod node to cycle to corral-3 after unpinned migrate, got %s", v.Node) + } + + // 3. Test get vmi + out, err := d.dispatch("", "kubectl", []string{"get", "vmi", "web-prod", "-n", "corral-vms", "-o", "json"}) + if err != nil { + t.Fatalf("get vmi failed: %v", err) + } + var vmi map[string]any + if err := json.Unmarshal(out, &vmi); err != nil { + t.Fatalf("failed to parse vmi json: %v", err) + } + status := vmi["status"].(map[string]any) + if status["nodeName"] != "corral-3" { + t.Errorf("expected vmi status.nodeName to be corral-3, got %v", status["nodeName"]) + } +} diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js index 384cec8f..cc4be14c 100644 --- a/pkg/web/static/app.js +++ b/pkg/web/static/app.js @@ -2,7 +2,10 @@ // Vanilla JS; noVNC + xterm.js vendored under static/vendor/ (offline-safe). import { icon } from './icons.js'; -import { bindPools, loadPools, poolState, renderTreePools, showMoveDialog, summariseOutcomes } from './pools.js'; +import { + bindPools, loadPools, poolState, renderTreePools, showMoveDialog, summariseOutcomes, + makeDraggable, dropZone, +} from './pools.js'; import { mountGrid } from './grid.js'; import { bindPalette, initKeys } from './palette.js'; @@ -1169,6 +1172,26 @@ function ctRow(c, lvl) { // Server View: Datacenter → Node → VMs/CTs, grouped by .node. Guests with // no placed node (stopped, unscheduled) render as top-level orphans. +// dropTargetNode accepts a dragged VM and proposes migrating it to that node. +// Invalid drops (node not ready, VM already there, non-KubeVirt VM) are +// refused with the reason as the row's tooltip. +function dropTargetNode(row, node) { + dropZone(row, { + defaultTitle: node.ready ? `Drop a VM here to migrate it to ${node.name}` : `Node ${node.name} (not ready)`, + checkValid: (vm) => { + if (!node.ready) return { ok: false, reason: `Node ${node.name} is not ready` }; + if (vm) { + if (vm.node === node.name) return { ok: false, reason: `${vm.name} is already on ${node.name}` }; + if (vm.backend && vm.backend !== 'kubevirt') { + return { ok: false, reason: `${vm.name} (${vm.backend}) cannot migrate to a cluster node` }; + } + } + return { ok: true }; + }, + onDrop: (vm) => { if (vm) migrateVM(vm, node.name); }, + }); +} + function renderTreeServer(tree) { const byNode = (nodeName) => vms.filter((v) => v.node === nodeName); const ctsByNode = (nodeName) => cts.filter((c) => c.node === nodeName); @@ -1183,6 +1206,7 @@ function renderTreeServer(tree) { onclick: () => select({ type: 'node', name: n.name }), }); attachContextMenu(row, () => nodeMenuItems(n.name)); + dropTargetNode(row, n); tree.appendChild(row); for (const vm of byNode(n.name)) { placed.add(vmKey(vm)); @@ -1265,6 +1289,7 @@ function vmRow(vm, lvl) { row.prepend(check); row.dataset.guest = vm.name; attachContextMenu(row, () => vmMenuItems(vm)); + makeDraggable(row, vm); return row; } @@ -2077,7 +2102,10 @@ function bindVMTable(root, list) { selected: selectedVMKeys, checkClass: 'vm-check', checkAllClass: 'vm-check-all', onRowClick: (vm) => select({ type: 'vm', key: vmKey(vm) }), onSelectionChange: () => { update(); renderTree(); }, - decorateRow: (tr, vm) => attachContextMenu(tr, () => vmMenuItems(vm)), + decorateRow: (tr, vm) => { + attachContextMenu(tr, () => vmMenuItems(vm)); + makeDraggable(tr, vm); + }, }); bar.querySelectorAll('[data-bulk]').forEach((b) => { @@ -2341,10 +2369,12 @@ async function post(vm, path, body) { }); } -async function migrateVM(vm) { +// migrateVM asks for (or, after a drag onto a node, confirms) the target node. +async function migrateVM(vm, targetNode = null) { + if (document.body.classList.contains('read-only') || !me.admin) return; const others = nodes.filter((n) => n.ready && n.name !== vm.node).map((n) => n.name); if (!others.length) { toast('No other ready node to migrate to.'); return; } - const target = await pickNode(vm, others); + const target = await pickNode(vm, others, targetNode); if (target === null) return; // cancelled let res; try { @@ -2399,21 +2429,28 @@ function pickExportFormat(vm) { } // pickNode shows a small modal with a target-node dropdown (eligible nodes -// only). Resolves to the chosen node name, '' for "let the scheduler choose", -// or null if cancelled. -function pickNode(vm, eligible) { +// only) and what kind of migration this will be. preselect is the node a VM +// was dropped on. Resolves to the chosen node name, '' for "let the scheduler +// choose", or null if cancelled. +function pickNode(vm, eligible, preselect = null) { return new Promise((resolve) => { const dlg = document.createElement('dialog'); - dlg.className = 'pick-dialog'; + dlg.className = 'pick-dialog migrate-dialog'; + const mode = !vm.running + ? `
  • Offline: the VM is stopped; it starts on the new node next time.
  • ` + : vm.liveMigratable + ? `
  • Live: memory and CPU state move with no downtime.
  • ` + : `
  • Not live-migratable: the migration may fail; stop the VM to move it offline.
  • `; dlg.innerHTML = `

    Migrate ${esc(vm.name)}

    -

    Currently on ${esc(vm.node || '—')}. Pick a target node.

    +

    Currently on ${esc(vm.node || '—')}. ${preselect ? `Move to ${esc(preselect)}?` : 'Pick a target node.'}

    +
      ${mode}
    @@ -3801,7 +3838,7 @@ $('#btn-create').innerHTML = `${icon('plus')} Create VM`; // pool row and a node row stay visually identical — the difference is what a // drop onto one means, not how it looks. bindPools({ - api, toast, esc, icon, refresh, treeRow, vmRow, + api, toast, esc, icon, refresh, treeRow, vmRow, vmKey, findVM, attachContextMenu, poolMenuItems, unassignedMenuItems, }); diff --git a/pkg/web/static/pools.js b/pkg/web/static/pools.js index a579109a..78ee5fdf 100644 --- a/pkg/web/static/pools.js +++ b/pkg/web/static/pools.js @@ -23,6 +23,7 @@ export function bindPools(helpers) { ctx = helpers; } let pools = { folders: [], unfoldered: [] }; let destinations = []; export const poolState = () => pools; +export const destinationsState = () => destinations; // loadPools refreshes both halves of the tree. Failures are soft: a pool tree // that cannot load should degrade to "no pools yet", not blank the sidebar. @@ -72,7 +73,7 @@ export function renderTreePools(tree) { for (const vm of folder.members || []) { const child = vmRow(vm, Math.min(depthOf(folder.path) + 2, 4)); - draggable(child, vm); + makeDraggable(child, vm); tree.appendChild(child); } for (const missing of folder.missing || []) { @@ -102,7 +103,7 @@ export function renderTreePools(tree) { tree.appendChild(loose); for (const vm of unassigned) { const row = vmRow(vm, 1); - draggable(row, vm); + makeDraggable(row, vm); tree.appendChild(row); } @@ -143,87 +144,180 @@ function renderBackendTargets(tree) { continue; } row.title = `Drop a VM here to move it to ${d.backend} (the guest stops)`; - dropTargetBackend(row, d.backend); + dropTargetBackend(row, d); tree.appendChild(row); } } -// ── drag and drop ───────────────────────────────────────────────── +// ── Unified drag and drop ────────────────────────────────────────── -const DRAG_TYPE = 'application/x-corral-instance'; +export const DRAG_TYPE = 'application/x-corral-instance'; +let activeDrag = null; -function draggable(row, vm) { +export const getActiveDrag = () => activeDrag; +export const setActiveDrag = (val) => { activeDrag = val; }; + +export function makeDraggable(row, vm) { + if (document.body.classList.contains('read-only')) { + row.draggable = false; + return; + } + // vmRow already makes tree rows draggable; pool view calls this again. + if (row.dataset.dragBound) return; + row.dataset.dragBound = '1'; row.draggable = true; row.addEventListener('dragstart', (e) => { - e.dataTransfer.setData(DRAG_TYPE, vm.id); + if (document.body.classList.contains('read-only')) { + e.preventDefault(); + return; + } + activeDrag = { type: 'vm', vm }; + e.dataTransfer.setData(DRAG_TYPE, vm.id || (ctx.vmKey ? ctx.vmKey(vm) : vm.name)); e.dataTransfer.setData('text/plain', vm.name); e.dataTransfer.effectAllowed = 'move'; row.classList.add('dragging'); }); - row.addEventListener('dragend', () => row.classList.remove('dragging')); + row.addEventListener('dragend', () => { + activeDrag = null; + row.classList.remove('dragging'); + document.querySelectorAll('.drop-target, .drop-invalid').forEach((el) => { + el.classList.remove('drop-target', 'drop-invalid'); + }); + }); } -// dropZone wires the three events every target needs identically, so a target -// differs only in what it does with the ref. -function dropZone(row, onDrop) { +// dropZone wires drag events with validation and feedback. +export function dropZone(row, { checkValid, onDrop, defaultTitle }) { + const origTitle = defaultTitle ?? row.title ?? ''; + + row.addEventListener('dragenter', (e) => { + if (!e.dataTransfer.types.includes(DRAG_TYPE)) return; + if (document.body.classList.contains('read-only')) return; + const vm = activeDrag?.vm; + const validity = checkValid ? checkValid(vm) : { ok: true }; + if (validity.ok) { + e.preventDefault(); + row.classList.add('drop-target'); + row.classList.remove('drop-invalid'); + } else { + row.classList.remove('drop-target'); + row.classList.add('drop-invalid'); + if (validity.reason) row.title = validity.reason; + } + }); + row.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes(DRAG_TYPE)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - row.classList.add('drop-target'); + if (document.body.classList.contains('read-only')) return; + const vm = activeDrag?.vm; + const validity = checkValid ? checkValid(vm) : { ok: true }; + if (validity.ok) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + row.classList.add('drop-target'); + row.classList.remove('drop-invalid'); + } else { + e.dataTransfer.dropEffect = 'none'; + row.classList.remove('drop-target'); + row.classList.add('drop-invalid'); + if (validity.reason) row.title = validity.reason; + } + }); + + row.addEventListener('dragleave', (e) => { + if (!row.contains(e.relatedTarget)) { + row.classList.remove('drop-target', 'drop-invalid'); + row.title = origTitle; + } }); - row.addEventListener('dragleave', () => row.classList.remove('drop-target')); + row.addEventListener('drop', (e) => { - row.classList.remove('drop-target'); + row.classList.remove('drop-target', 'drop-invalid'); + row.title = origTitle; + if (document.body.classList.contains('read-only')) return; const ref = e.dataTransfer.getData(DRAG_TYPE); if (!ref) return; + const vm = activeDrag?.vm || (ctx.findVM ? ctx.findVM(ref) : null); + const validity = checkValid ? checkValid(vm) : { ok: true }; + if (!validity.ok) { + if (validity.reason) ctx.toast(validity.reason); + return; + } e.preventDefault(); e.stopPropagation(); - onDrop(ref); + onDrop(vm, ref); }); } // Dropping onto a pool is grouping only — no instance is touched, so it commits // immediately rather than asking. Dragging it back undoes it. -function dropTargetPool(row, path) { - dropZone(row, async (ref) => { - try { - if (path === '') { - await ctx.api(`/api/folders/members?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' }); - ctx.toast('Removed from its pool'); - } else { - await ctx.api('/api/folders/members', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path, ref }), - }); - ctx.toast(`Added to ${path}`); +export function dropTargetPool(row, path) { + dropZone(row, { + defaultTitle: path ? `Pool ${path}` : 'Unassigned pool', + checkValid: (vm) => { + if (vm && path !== '') { + const folder = pools.folders.find((f) => f.path === path); + if (folder && (folder.members || []).some((m) => (m.id && m.id === vm.id) || m.name === vm.name)) { + return { ok: false, reason: `${vm.name} is already in pool ${path}` }; + } } - } catch (e) { - ctx.toast(e.message); - return; - } - await loadPools(); - ctx.refresh(true); + return { ok: true }; + }, + onDrop: async (vm, ref) => { + try { + if (path === '') { + await ctx.api(`/api/folders/members?ref=${encodeURIComponent(ref)}`, { method: 'DELETE' }); + ctx.toast('Removed from its pool'); + } else { + await ctx.api('/api/folders/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, ref }), + }); + ctx.toast(`Added to ${path}`); + } + } catch (e) { + ctx.toast(e.message); + return; + } + await loadPools(); + ctx.refresh(true); + }, }); } // Dropping onto a backend proposes a move. The drop itself only asks the server // for a plan — safe to do on every stray drag. -function dropTargetBackend(row, backend) { - dropZone(row, async (ref) => { - let plan; - try { - plan = await ctx.api('/api/move/preflight', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ref, toBackend: backend }), - }); - } catch (e) { - ctx.toast(`Could not plan the move: ${e.message}`); - return; - } - showMoveDialog(ref, backend, plan); +export function dropTargetBackend(row, d) { + const backend = typeof d === 'string' ? d : d.backend; + const can = typeof d === 'string' ? true : d.can; + const reason = typeof d === 'string' ? '' : d.reason; + + dropZone(row, { + defaultTitle: can ? `Drop a VM here to move it to ${backend} (the guest stops)` : (reason || `${backend} unavailable`), + checkValid: (vm) => { + if (!can) { + return { ok: false, reason: reason || `${backend} cannot receive a moved instance` }; + } + if (vm && vm.backend === backend && (!vm.context || vm.context === (d.context || ''))) { + return { ok: false, reason: `${vm.name} is already on backend ${backend}` }; + } + return { ok: true }; + }, + onDrop: async (vm, ref) => { + let plan; + try { + plan = await ctx.api('/api/move/preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ref, toBackend: backend }), + }); + } catch (e) { + ctx.toast(`Could not plan the move: ${e.message}`); + return; + } + showMoveDialog(ref, backend, plan); + }, }); } diff --git a/pkg/web/static/style.css b/pkg/web/static/style.css index 3e95f15c..286c973c 100644 --- a/pkg/web/static/style.css +++ b/pkg/web/static/style.css @@ -181,6 +181,7 @@ main { that would only ever 403, leaving view-only surfaces (consoles, tabs). */ body.read-only #btn-create, body.read-only #btn-create-ct, +body.read-only .pool-actions, body.read-only .toolbar, body.read-only .hp-action, body.read-only .bulkbar button[data-bulk], @@ -706,6 +707,11 @@ tr.ev-warn td { color: var(--yellow); } outline-offset: -2px; background: var(--panel-2); } +.tree-item.drop-invalid { + outline: 2px dashed var(--red, #f85149); + outline-offset: -2px; + cursor: not-allowed; +} /* A backend that cannot receive a move: visibly inert, with the reason on hover. It is never a drop target, so it never highlights. */ .tree-item.disabled { opacity: .45; cursor: not-allowed; } @@ -720,6 +726,9 @@ tr.ev-warn td { color: var(--yellow); } .pool-actions { visibility: hidden; } .tree-item:hover .pool-actions, .tree-item:focus-within .pool-actions { visibility: visible; } +ul.migrate-checks { list-style: none; padding: 0; margin: 12px 0; } +.migrate-checks li { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: .85rem; } + .modal { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; diff --git a/scripts/ui-smoke.mjs b/scripts/ui-smoke.mjs index 80b52039..b57a594c 100644 --- a/scripts/ui-smoke.mjs +++ b/scripts/ui-smoke.mjs @@ -255,6 +255,35 @@ const updated = await putRes.json(); check(updated.accent === '#22c55e' || updated.error, 'PUT /api/theme accepts accent change'); +// ── drag-migrate: drag a VM onto a node in Server View ───────────── +// The drop opens the migrate confirmation with that node preselected; the +// confirm runs POST /api/vms/{ns}/{name}/migrate and the VM lands there. +await page.click('#tree >> text=Server View'); +await page.waitForTimeout(800); +const fleetBeforeMigrate = await (await fetch(`${BASE}api/vms`)).json(); +const fromNode = fleetBeforeMigrate.find((v) => v.name === 'web-prod')?.node; +const toNode = ['corral-1', 'corral-2', 'corral-3'].find((n) => n !== fromNode); +const migrateSubject = page.locator('#tree .tree-item', { hasText: 'web-prod' }).first(); +const migrateTarget = page.locator('#tree .tree-item', { hasText: toNode }).first(); +await migrateSubject.dragTo(migrateTarget); +await page.waitForSelector('.migrate-dialog[open]', { timeout: 5000 }).catch(() => {}); +check(await page.locator('.migrate-dialog[open]').count() === 1, `drag-migrate: dropping web-prod on ${toNode} opens the confirmation`); +check( + await page.locator('.migrate-dialog #pick-node').inputValue().catch(() => '') === toNode, + 'drag-migrate: the dropped-on node is preselected', +); +await page.screenshot({ path: 'drag-migrate.png' }).catch(() => {}); +await page.click('.migrate-dialog #pick-go').catch(() => {}); +await page.waitForFunction(async ([target]) => { + const fleet = await (await fetch('/api/vms')).json(); + return fleet.find((v) => v.name === 'web-prod')?.node === target; +}, [toNode], { timeout: 10000 }).catch(() => {}); +const fleetAfterMigrate = await (await fetch(`${BASE}api/vms`)).json(); +const migratedNode = fleetAfterMigrate.find((v) => v.name === 'web-prod')?.node; +check(migratedNode === toNode, `drag-migrate: web-prod moved ${fromNode} → ${migratedNode} (want ${toNode})`); +// The migration's task log opens as a modal; close it before moving on. +if (await page.locator('#build-dialog[open]').count()) await page.click('#btn-build-close'); + // ── Pool View: drag-and-drop grouping and drag-to-move ──────────── // The drop targets are the whole point of this view, and the two kinds must // behave differently: a pool drop regroups silently, a backend drop must open