update virtual-machines.tsx

This commit is contained in:
MacRimi
2026-04-17 17:36:57 +02:00
parent c7b49cfc4a
commit 03850d2958
5 changed files with 95 additions and 105 deletions

View File

@@ -948,23 +948,31 @@ export function Security() {
} }
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try { try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} else { return true
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
} }
return true } catch {
// fall through to execCommand fallback
}
try {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.left = "-9999px"
textarea.style.top = "-9999px"
textarea.style.opacity = "0"
textarea.readOnly = true
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
const ok = document.execCommand("copy")
document.body.removeChild(textarea)
return ok
} catch { } catch {
return false return false
} }

View File

@@ -293,10 +293,44 @@ export function Settings() {
} }
} }
const copySourceCode = () => { const copySourceCode = async () => {
navigator.clipboard.writeText(codeModal.source) const text = codeModal.source
setCodeCopied(true) let ok = false
setTimeout(() => setCodeCopied(false), 2000)
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
ok = true
}
} catch {
// fall through
}
if (!ok) {
try {
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.left = "-9999px"
ta.style.top = "-9999px"
ta.style.opacity = "0"
ta.readOnly = true
document.body.appendChild(ta)
ta.focus()
ta.select()
ok = document.execCommand("copy")
document.body.removeChild(ta)
} catch {
ok = false
}
}
if (ok) {
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
}
} }
const changeNetworkUnit = (unit: string) => { const changeNetworkUnit = (unit: string) => {

View File

@@ -90,33 +90,49 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
} }
const copyToClipboard = async (text: string, type: "secret" | "codes") => { const copyToClipboard = async (text: string, type: "secret" | "codes") => {
let ok = false
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
// so we catch and fall through to the textarea fallback.
try { try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} else { ok = true
// Fallback for non-secure contexts (HTTP) }
} catch {
// fall through to execCommand fallback
}
if (!ok) {
try {
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
textarea.value = text textarea.value = text
textarea.style.position = "fixed" textarea.style.position = "fixed"
textarea.style.left = "-9999px" textarea.style.left = "-9999px"
textarea.style.top = "-9999px" textarea.style.top = "-9999px"
textarea.style.opacity = "0" textarea.style.opacity = "0"
textarea.readOnly = true
document.body.appendChild(textarea) document.body.appendChild(textarea)
textarea.focus() textarea.focus()
textarea.select() textarea.select()
document.execCommand("copy") ok = document.execCommand("copy")
document.body.removeChild(textarea) document.body.removeChild(textarea)
} catch {
ok = false
} }
}
if (type === "secret") { if (!ok) {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
} catch {
console.error("Failed to copy to clipboard") console.error("Failed to copy to clipboard")
return
}
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
} }
} }

View File

@@ -295,10 +295,10 @@ export function VirtualMachines() {
isLoading, isLoading,
mutate, mutate,
} = useSWR<VMData[]>("/api/vms", fetcher, { } = useSWR<VMData[]>("/api/vms", fetcher, {
refreshInterval: 5000, refreshInterval: 2500,
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateOnReconnect: true, revalidateOnReconnect: true,
dedupingInterval: 2000, dedupingInterval: 1000,
errorRetryCount: 2, errorRetryCount: 2,
}) })
@@ -423,36 +423,16 @@ export function VirtualMachines() {
} }
}, []) }, [])
// Keep the open modal's VM in sync with the 5s poll of /api/vms so CPU/RAM/I-O // Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values
// values don't stay frozen at click-time while the user has the modal open. // don't stay frozen at click-time. Single data source (/cluster/resources) shared
// with the list — no source mismatch, no flicker.
useEffect(() => { useEffect(() => {
if (!selectedVM || !vmData) return if (!selectedVM || !vmData) return
const updated = vmData.find((v) => v.vmid === selectedVM.vmid) const updated = vmData.find((v) => v.vmid === selectedVM.vmid)
if (!updated) return if (!updated || updated === selectedVM) return
// Avoid unnecessary setState when no field changed (reference-equal shortcut first).
if (updated === selectedVM) return
setSelectedVM(updated) setSelectedVM(updated)
}, [vmData]) }, [vmData])
// Faster per-VM live status poll that only runs while the modal is open.
// SWR disables polling when the key is null, so this is truly scoped to the modal.
const { data: liveVMStatus } = useSWR<VMData>(
selectedVM ? `/api/vms/${selectedVM.vmid}/status` : null,
fetcher,
{
refreshInterval: 2500,
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 1000,
},
)
useEffect(() => {
if (!liveVMStatus || !selectedVM) return
if (liveVMStatus.vmid !== selectedVM.vmid) return
setSelectedVM((prev) => (prev ? { ...prev, ...liveVMStatus } : prev))
}, [liveVMStatus])
const handleVMClick = async (vm: VMData) => { const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm) setSelectedVM(vm)
setCurrentView("main") setCurrentView("main")

View File

@@ -1167,7 +1167,7 @@ _pvesh_cache = {
'storage_list': None, 'storage_list': None,
'storage_list_time': 0, 'storage_list_time': 0,
} }
_PVESH_CACHE_TTL = 5 # 5 seconds - near real-time for active UI; pvesh local cost is ~200-400ms _PVESH_CACHE_TTL = 2 # 2 seconds - near real-time, single consistent data source for list + modal
# Cache for sensors output (temperature readings) # Cache for sensors output (temperature readings)
_sensors_cache = { _sensors_cache = {
@@ -8047,54 +8047,6 @@ def api_vms():
return jsonify(get_proxmox_vms()) return jsonify(get_proxmox_vms())
@app.route('/api/vms/<int:vmid>/status', methods=['GET'])
@require_auth
def api_vm_status(vmid):
"""Lightweight per-VM live status: cpu, mem, disk, I/O counters, uptime.
Designed to be polled every 2-3s from the detail modal while it's open.
Single pvesh call (~200-400ms local socket); returns the same shape as
/api/vms entries so the frontend can swap in-place.
"""
try:
local_node = get_proxmox_node_name()
result = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/qemu/{vmid}/status/current', '--output-format', 'json'],
capture_output=True, text=True, timeout=10
)
vm_type = 'qemu'
if result.returncode != 0:
result = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/lxc/{vmid}/status/current', '--output-format', 'json'],
capture_output=True, text=True, timeout=10
)
vm_type = 'lxc'
if result.returncode != 0:
return jsonify({'error': f'VM/LXC {vmid} not found'}), 404
data = json.loads(result.stdout)
return jsonify({
'vmid': vmid,
'name': data.get('name', f'VM-{vmid}'),
'status': data.get('status', 'unknown'),
'type': vm_type if vm_type == 'lxc' else 'qemu',
'cpu': data.get('cpu', 0),
'mem': data.get('mem', 0),
'maxmem': data.get('maxmem', 0),
'disk': data.get('disk', 0),
'maxdisk': data.get('maxdisk', 0),
'uptime': data.get('uptime', 0),
'netin': data.get('netin', 0),
'netout': data.get('netout', 0),
'diskread': data.get('diskread', 0),
'diskwrite': data.get('diskwrite', 0),
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/metrics', methods=['GET']) @app.route('/api/vms/<int:vmid>/metrics', methods=['GET'])
@require_auth @require_auth
def api_vm_metrics(vmid): def api_vm_metrics(vmid):