diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 0a32f089..8f139f3b 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -948,23 +948,31 @@ export function Security() { } 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 { - if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) - } else { - 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 } - 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 { return false } diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 6e235405..f5760fdc 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -293,10 +293,44 @@ export function Settings() { } } - const copySourceCode = () => { - navigator.clipboard.writeText(codeModal.source) - setCodeCopied(true) - setTimeout(() => setCodeCopied(false), 2000) + const copySourceCode = async () => { + const text = codeModal.source + let ok = false + + // 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) => { diff --git a/AppImage/components/two-factor-setup.tsx b/AppImage/components/two-factor-setup.tsx index 951f92cf..15f649d6 100644 --- a/AppImage/components/two-factor-setup.tsx +++ b/AppImage/components/two-factor-setup.tsx @@ -90,33 +90,49 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps } 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 { - if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) - } else { - // Fallback for non-secure contexts (HTTP) + ok = true + } + } catch { + // fall through to execCommand fallback + } + + if (!ok) { + 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() - document.execCommand("copy") + ok = document.execCommand("copy") document.body.removeChild(textarea) + } catch { + ok = false } + } - if (type === "secret") { - setCopiedSecret(true) - setTimeout(() => setCopiedSecret(false), 2000) - } else { - setCopiedCodes(true) - setTimeout(() => setCopiedCodes(false), 2000) - } - } catch { + if (!ok) { console.error("Failed to copy to clipboard") + return + } + + if (type === "secret") { + setCopiedSecret(true) + setTimeout(() => setCopiedSecret(false), 2000) + } else { + setCopiedCodes(true) + setTimeout(() => setCopiedCodes(false), 2000) } } diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 3441c211..d1962351 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -295,10 +295,10 @@ export function VirtualMachines() { isLoading, mutate, } = useSWR("/api/vms", fetcher, { - refreshInterval: 5000, + refreshInterval: 2500, revalidateOnFocus: true, revalidateOnReconnect: true, - dedupingInterval: 2000, + dedupingInterval: 1000, 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 - // values don't stay frozen at click-time while the user has the modal open. + // Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values + // don't stay frozen at click-time. Single data source (/cluster/resources) shared + // with the list — no source mismatch, no flicker. useEffect(() => { if (!selectedVM || !vmData) return const updated = vmData.find((v) => v.vmid === selectedVM.vmid) - if (!updated) return - // Avoid unnecessary setState when no field changed (reference-equal shortcut first). - if (updated === selectedVM) return + if (!updated || updated === selectedVM) return setSelectedVM(updated) }, [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( - 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) => { setSelectedVM(vm) setCurrentView("main") diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 1264b932..b34e68e3 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -1167,7 +1167,7 @@ _pvesh_cache = { 'storage_list': None, '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) _sensors_cache = { @@ -8047,54 +8047,6 @@ def api_vms(): return jsonify(get_proxmox_vms()) -@app.route('/api/vms//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//metrics', methods=['GET']) @require_auth def api_vm_metrics(vmid):