mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 00:46:21 +00:00
update virtual-machines.tsx
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user