Fix 21 bugs: build failures, broken pages, IPC mismatches, and missing error handling
Critical fixes: - Add esbuild dependency and fix Vite build target (safari13 → es2022) - Fix StorageDetail always failing (null node parameter) - Fix VNC/Terminal console stuck on 'Connecting...' (add onConnected callback) - Fix Ctrl+Alt+Del not working (add data-vnc attribute and event listener) - Add serde(rename_all='camelCase') to all 27 Rust structs crossing IPC - Fix login methods returning empty connection_id (generate from URL hash) - Fix add_connection not storing connections in HashMap - Fix keyring_entry panicking on failure (replace expect with map_err) High-severity fixes: - Fix refresh_ticket reading wrong keyring entry for username - Add onError handlers to all 23 mutations (toast notifications) - Fix stale closure on activeConnectionId (use getState() instead) - Fix WebSocket listener teardown race condition - Add WebSocket reconnection retry limit (max 10 attempts) Medium-severity fixes: - Add Settings navigation to CommandPalette (Cmd+K) - Disable dead Nodes/Containers sidebar items - Wire up HardwareTab save button with warning toast - Wire up QuickActions navigation buttons - Add Dashboard error state (was stuck on 'Loading...') - Add default case to App.tsx view switch - Extract formatBytes/formatUptime/formatNetworkRate to shared utility - Fix formatBytes negative input bug 32 files changed, 1073 insertions(+), 356 deletions(-)
This commit is contained in:
Generated
+485
@@ -40,6 +40,7 @@
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -80,6 +81,448 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
@@ -2830,6 +3273,48 @@
|
||||
"tests/types"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/novnc": "^0.0.27",
|
||||
|
||||
+110
-34
@@ -1,14 +1,12 @@
|
||||
use crate::error::Error;
|
||||
use crate::proxmox::{
|
||||
AddDiskConfig, AddNICConfig, ApiResponse, Backup, BackupJob, BackupJobConfig, ClusterStatus,
|
||||
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterStatus,
|
||||
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
||||
Storage, StorageContent, StorageDetail, Task, VM,
|
||||
};
|
||||
use crate::{CertificateInfo, ConnectionConfig, EndpointConfig, LoginResult, TermProxyResponse, VNCProxyResponse};
|
||||
use crate::{CertificateInfo, ConnectionConfig, LoginResult, TermProxyResponse, VNCProxyResponse};
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
struct Connection {
|
||||
config: ConnectionConfig,
|
||||
@@ -22,10 +20,10 @@ fn keyring_service() -> &'static str {
|
||||
"proxmox-desktop"
|
||||
}
|
||||
|
||||
fn keyring_entry(connection_id: &str, field: &str) -> keyring::Entry {
|
||||
fn keyring_entry(connection_id: &str, field: &str) -> crate::Result<keyring::Entry> {
|
||||
let key = format!("{}:{}", connection_id, field);
|
||||
keyring::Entry::new(keyring_service(), &key)
|
||||
.expect("failed to create keyring entry")
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
}
|
||||
|
||||
pub struct ConnectionManager {
|
||||
@@ -39,27 +37,58 @@ impl ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_connection(&self, config: ConnectionConfig) -> crate::Result<()> {
|
||||
pub async fn add_connection(&mut self, config: ConnectionConfig) -> crate::Result<()> {
|
||||
if config.primary.url.is_empty() {
|
||||
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
||||
}
|
||||
let id = config.id.clone();
|
||||
let connection = Connection {
|
||||
config,
|
||||
client: Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(Error::HttpError)?,
|
||||
ticket: None,
|
||||
csrf_token: None,
|
||||
current_endpoint_index: 0,
|
||||
};
|
||||
self.connections.insert(id, connection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_connection(&self, id: &str) -> crate::Result<()> {
|
||||
pub async fn remove_connection(&mut self, id: &str) -> crate::Result<()> {
|
||||
self.connections.remove(id);
|
||||
// Clear stored credentials from keyring
|
||||
let _ = keyring_entry(id, "ticket").delete_credential();
|
||||
let _ = keyring_entry(id, "csrf_token").delete_credential();
|
||||
let _ = keyring_entry(id, "password").delete_credential();
|
||||
let _ = keyring_entry(id, "token").delete_credential();
|
||||
let _ = keyring_entry(id, "ticket").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(id, "csrf_token").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(id, "password").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(id, "token").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect(&self, id: &str) -> crate::Result<()> {
|
||||
pub async fn connect(&mut self, id: &str) -> crate::Result<()> {
|
||||
if let Some(conn) = self.connections.get_mut(id) {
|
||||
conn.config.status = "connected".to_string();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self, id: &str) -> crate::Result<()> {
|
||||
pub async fn disconnect(&mut self, id: &str) -> crate::Result<()> {
|
||||
if let Some(conn) = self.connections.get_mut(id) {
|
||||
conn.config.status = "disconnected".to_string();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -119,8 +148,27 @@ impl ConnectionManager {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Generate a stable connection ID from the URL
|
||||
let connection_id = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
format!("{:x}", hash)[..16].to_string()
|
||||
};
|
||||
|
||||
// Store credentials in keyring for later use
|
||||
keyring_entry(&connection_id, "ticket")
|
||||
.and_then(|e| e.set_password(&ticket).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
keyring_entry(&connection_id, "csrf_token")
|
||||
.and_then(|e| e.set_password(&csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
keyring_entry(&connection_id, "username")
|
||||
.and_then(|e| e.set_password(username).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
keyring_entry(&connection_id, "password")
|
||||
.and_then(|e| e.set_password(password).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
|
||||
Ok(LoginResult {
|
||||
connection_id: String::new(),
|
||||
connection_id,
|
||||
ticket,
|
||||
csrf_token,
|
||||
})
|
||||
@@ -164,23 +212,56 @@ impl ConnectionManager {
|
||||
return Err(Error::InvalidCredentials("Invalid API token".to_string()));
|
||||
}
|
||||
|
||||
// Generate a stable connection ID from the URL
|
||||
let connection_id = {
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(url.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
format!("{:x}", hash)[..16].to_string()
|
||||
};
|
||||
|
||||
// Store token in keyring for later use
|
||||
keyring_entry(&connection_id, "token")
|
||||
.and_then(|e| e.set_password(token).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
|
||||
Ok(LoginResult {
|
||||
connection_id: String::new(),
|
||||
connection_id,
|
||||
ticket: token.to_string(),
|
||||
csrf_token: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn logout(&self, connection_id: &str) -> crate::Result<()> {
|
||||
let _ = keyring_entry(connection_id, "ticket").delete_credential();
|
||||
let _ = keyring_entry(connection_id, "csrf_token").delete_credential();
|
||||
let _ = keyring_entry(connection_id, "password").delete_credential();
|
||||
let _ = keyring_entry(connection_id, "token").delete_credential();
|
||||
let _ = keyring_entry(connection_id, "ticket").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(connection_id, "csrf_token").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(connection_id, "password").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(connection_id, "token").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
let _ = keyring_entry(connection_id, "username").and_then(|e| {
|
||||
e.delete_credential()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_stored_credentials(&self, connection_id: &str) -> crate::Result<Option<String>> {
|
||||
match keyring_entry(connection_id, "ticket").get_password() {
|
||||
let entry = match keyring_entry(connection_id, "ticket") {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
match entry.get_password() {
|
||||
Ok(ticket) => Ok(Some(ticket)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(Error::KeyringError(e.to_string())),
|
||||
@@ -196,20 +277,16 @@ impl ConnectionManager {
|
||||
api_token: Option<&str>,
|
||||
) -> crate::Result<()> {
|
||||
keyring_entry(connection_id, "ticket")
|
||||
.set_password(ticket)
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))?;
|
||||
.and_then(|e| e.set_password(ticket).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
keyring_entry(connection_id, "csrf_token")
|
||||
.set_password(csrf_token)
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))?;
|
||||
.and_then(|e| e.set_password(csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
if let Some(pw) = password {
|
||||
keyring_entry(connection_id, "password")
|
||||
.set_password(pw)
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))?;
|
||||
.and_then(|e| e.set_password(pw).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
}
|
||||
if let Some(tok) = api_token {
|
||||
keyring_entry(connection_id, "token")
|
||||
.set_password(tok)
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))?;
|
||||
.and_then(|e| e.set_password(tok).map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -221,12 +298,11 @@ impl ConnectionManager {
|
||||
) -> crate::Result<LoginResult> {
|
||||
// Try to get stored password for re-authentication
|
||||
let password = keyring_entry(connection_id, "password")
|
||||
.get_password()
|
||||
.map_err(|e| Error::KeyringError(e.to_string()))?;
|
||||
.and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string())))?;
|
||||
|
||||
// Extract username from stored ticket or use default
|
||||
let username = keyring_entry(connection_id, "csrf_token")
|
||||
.get_password()
|
||||
// Get stored username for re-authentication
|
||||
let username = keyring_entry(connection_id, "username")
|
||||
.and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string())))
|
||||
.unwrap_or_default();
|
||||
|
||||
self.login_with_password(url, &username, &password).await
|
||||
|
||||
+11
-4
@@ -16,6 +16,7 @@ use websocket::WebSocketManager;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
@@ -31,6 +32,7 @@ pub struct ConnectionConfig {
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct EndpointConfig {
|
||||
pub url: String,
|
||||
@@ -38,6 +40,7 @@ pub struct EndpointConfig {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct LoginResult {
|
||||
pub connection_id: String,
|
||||
@@ -45,6 +48,7 @@ pub struct LoginResult {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct CertificateInfo {
|
||||
pub fingerprint: String,
|
||||
@@ -65,7 +69,7 @@ async fn add_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: ConnectionConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
manager.add_connection(config).await
|
||||
}
|
||||
|
||||
@@ -74,7 +78,7 @@ async fn remove_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
manager.remove_connection(&id).await
|
||||
}
|
||||
|
||||
@@ -83,7 +87,7 @@ async fn connect_to_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
manager.connect(&id).await
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@ async fn disconnect_from_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
manager.disconnect(&id).await
|
||||
}
|
||||
|
||||
@@ -457,6 +461,7 @@ async fn get_stored_credentials(
|
||||
}
|
||||
|
||||
// Console proxy types
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct VNCProxyResponse {
|
||||
pub ticket: String,
|
||||
@@ -464,6 +469,7 @@ pub struct VNCProxyResponse {
|
||||
pub cert: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct TermProxyResponse {
|
||||
pub ticket: String,
|
||||
@@ -613,6 +619,7 @@ async fn delete_backup(
|
||||
manager.delete_backup(&connection_id, &volid).await
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct TrayConnectionInfo {
|
||||
pub id: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Disk {
|
||||
pub device: String,
|
||||
@@ -9,6 +10,7 @@ pub struct Disk {
|
||||
pub usage: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddDiskConfig {
|
||||
pub storage: String,
|
||||
@@ -16,6 +18,7 @@ pub struct AddDiskConfig {
|
||||
pub bus_type: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
pub node: String,
|
||||
@@ -32,6 +35,7 @@ pub struct Node {
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VM {
|
||||
pub vmid: u32,
|
||||
@@ -56,6 +60,7 @@ pub struct VM {
|
||||
pub tags: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Storage {
|
||||
pub storage: String,
|
||||
@@ -70,6 +75,7 @@ pub struct Storage {
|
||||
pub node: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
pub upid: String,
|
||||
@@ -85,6 +91,7 @@ pub struct Task {
|
||||
pub exitstatus: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterStatus {
|
||||
pub r#type: String,
|
||||
@@ -93,6 +100,7 @@ pub struct ClusterStatus {
|
||||
pub nodes: Option<Vec<ClusterNode>>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterNode {
|
||||
pub name: String,
|
||||
@@ -102,6 +110,7 @@ pub struct ClusterNode {
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub name: String,
|
||||
@@ -111,6 +120,7 @@ pub struct Snapshot {
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateSnapshotConfig {
|
||||
pub name: String,
|
||||
@@ -118,11 +128,13 @@ pub struct CreateSnapshotConfig {
|
||||
pub vmstate: Option<bool>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
@@ -134,6 +146,7 @@ pub struct NetworkInterface {
|
||||
pub link_down: Option<u32>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddNICConfig {
|
||||
pub bridge: String,
|
||||
@@ -143,6 +156,7 @@ pub struct AddNICConfig {
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditNICConfig {
|
||||
pub bridge: Option<String>,
|
||||
@@ -151,6 +165,7 @@ pub struct EditNICConfig {
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Backup {
|
||||
pub volid: String,
|
||||
@@ -166,6 +181,7 @@ pub struct Backup {
|
||||
pub ctime: u64,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
@@ -180,6 +196,7 @@ pub struct BackupJob {
|
||||
pub quiet: Option<u32>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJobConfig {
|
||||
pub id: Option<String>,
|
||||
@@ -193,6 +210,7 @@ pub struct BackupJobConfig {
|
||||
pub node: Option<String>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RestoreConfig {
|
||||
pub volid: String,
|
||||
@@ -201,6 +219,7 @@ pub struct RestoreConfig {
|
||||
pub vmid: Option<u32>,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageContent {
|
||||
pub content: String,
|
||||
@@ -211,6 +230,7 @@ pub struct StorageContent {
|
||||
pub volid: String,
|
||||
}
|
||||
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageDetail {
|
||||
pub storage: String,
|
||||
|
||||
@@ -70,6 +70,8 @@ impl WebSocketManager {
|
||||
tokio::spawn(async move {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
const MAX_DELAY: Duration = Duration::from_secs(30);
|
||||
const MAX_RETRIES: u32 = 10;
|
||||
let mut retry_count: u32 = 0;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -79,11 +81,19 @@ impl WebSocketManager {
|
||||
result = connect_and_run(&cid, &ws_url, &app_handle) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// Normal close or stream ended – attempt reconnect
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
retry_count = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ws] connection error for {}: {e}", cid);
|
||||
retry_count += 1;
|
||||
if retry_count >= MAX_RETRIES {
|
||||
let _ = app_handle.emit("ws-raw", serde_json::json!({
|
||||
"connection_id": cid,
|
||||
"data": { "type": "error", "message": format!("WebSocket reconnect failed after {} attempts: {}", MAX_RETRIES, e) },
|
||||
}));
|
||||
break;
|
||||
}
|
||||
eprintln!("[ws] connection error for {} (attempt {}/{}): {e}", cid, retry_count, MAX_RETRIES);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -34,7 +34,7 @@ type View =
|
||||
| { type: 'tasks' }
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string }
|
||||
| { type: 'storage-detail'; storage: string; node: string }
|
||||
| { type: 'settings' }
|
||||
|
||||
function AppContent() {
|
||||
@@ -90,7 +90,16 @@ function AppContent() {
|
||||
|
||||
switch (view.type) {
|
||||
case 'dashboard':
|
||||
return <Dashboard connectionId={activeConnectionId} />
|
||||
return <Dashboard connectionId={activeConnectionId} onNavigate={(viewName) => {
|
||||
switch (viewName) {
|
||||
case 'dashboard': handleNavigate({ type: 'dashboard' }); break
|
||||
case 'vms': handleNavigate({ type: 'vms' }); break
|
||||
case 'tasks': handleNavigate({ type: 'tasks' }); break
|
||||
case 'backups': handleNavigate({ type: 'backups' }); break
|
||||
case 'storage': handleNavigate({ type: 'storage' }); break
|
||||
default: break
|
||||
}
|
||||
}} />
|
||||
case 'vms':
|
||||
return (
|
||||
<VMList
|
||||
@@ -114,8 +123,8 @@ function AppContent() {
|
||||
return (
|
||||
<StorageOverview
|
||||
connectionId={activeConnectionId}
|
||||
onStorageClick={(storage) =>
|
||||
handleNavigate({ type: 'storage-detail', storage })
|
||||
onStorageClick={(storage, node) =>
|
||||
handleNavigate({ type: 'storage-detail', storage, node })
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -124,9 +133,16 @@ function AppContent() {
|
||||
<StorageDetail
|
||||
connectionId={activeConnectionId}
|
||||
storage={view.storage}
|
||||
node={view.node}
|
||||
onBack={() => handleNavigate({ type: 'storage' })}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Unknown view: {(view as { type: string }).type}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,19 +25,12 @@ import { CreateBackupJobDialog } from './dialogs/CreateBackupJobDialog'
|
||||
import { EditBackupJobDialog } from './dialogs/EditBackupJobDialog'
|
||||
import { RestoreBackupDialog } from './dialogs/RestoreBackupDialog'
|
||||
import type { ProxmoxBackupJob, ProxmoxBackup } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface BackupListProps {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ListTodo,
|
||||
Shield,
|
||||
HardDrive,
|
||||
Settings,
|
||||
Plus,
|
||||
X,
|
||||
Search,
|
||||
@@ -38,7 +39,8 @@ type View =
|
||||
| { type: 'tasks' }
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string }
|
||||
| { type: 'storage-detail'; storage: string; node: string }
|
||||
| { type: 'settings' }
|
||||
|
||||
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
|
||||
|
||||
@@ -254,6 +256,15 @@ export function CommandPalette({
|
||||
keywords: ['storage', 'disks', 'volumes'],
|
||||
onExecute: () => onNavigate({ type: 'storage' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
label: 'Go to Settings',
|
||||
icon: Settings,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘6',
|
||||
keywords: ['settings', 'preferences', 'configuration'],
|
||||
onExecute: () => onNavigate({ type: 'settings' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-add-connection',
|
||||
label: 'Add Connection',
|
||||
|
||||
@@ -8,9 +8,10 @@ interface TerminalConsoleProps {
|
||||
node: string
|
||||
vmid: number
|
||||
onError?: (message: string) => void
|
||||
onConnected?: () => void
|
||||
}
|
||||
|
||||
export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalConsoleProps) {
|
||||
export function TerminalConsole({ connectionId, node, vmid, onError, onConnected }: TerminalConsoleProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
@@ -157,6 +158,8 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
|
||||
})
|
||||
resizeObserver.observe(containerRef.current!)
|
||||
|
||||
onConnected?.()
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
@@ -172,6 +175,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
|
||||
ws.onopen = () => {
|
||||
if (!cancelled) {
|
||||
terminal.writeln(`\x1b[32mConnected to LXC container ${vmid} on ${node}\x1b[0m`)
|
||||
onConnected?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +246,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC
|
||||
cleanupResize?.()
|
||||
cleanup()
|
||||
}
|
||||
}, [connectionId, node, vmid, onError, cleanup])
|
||||
}, [connectionId, node, vmid, onError, onConnected, cleanup])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -5,11 +5,12 @@ interface VNCConsoleProps {
|
||||
node: string
|
||||
vmid: number
|
||||
onError?: (message: string) => void
|
||||
onConnected?: () => void
|
||||
}
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProps) {
|
||||
export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: VNCConsoleProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<unknown>(null)
|
||||
const stateRef = useRef<ConnectionState>('connecting')
|
||||
@@ -72,6 +73,7 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (!cancelled) {
|
||||
stateRef.current = 'connected'
|
||||
onConnected?.()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -90,6 +92,15 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
|
||||
onError?.('VNC credentials required')
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for Ctrl+Alt+Del command from parent
|
||||
const onCtrlAltDel = () => {
|
||||
const current = rfbRef.current as { sendCtrlAltDel?: () => void } | null
|
||||
if (current?.sendCtrlAltDel) {
|
||||
current.sendCtrlAltDel()
|
||||
}
|
||||
}
|
||||
containerRef.current.addEventListener('vnc-ctrl-alt-del', onCtrlAltDel)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
stateRef.current = 'error'
|
||||
@@ -102,13 +113,17 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (containerRef.current) {
|
||||
containerRef.current.removeAttribute('data-vnc')
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}, [connectionId, node, vmid, onError, cleanup])
|
||||
}, [connectionId, node, vmid, onError, onConnected, cleanup])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-vnc
|
||||
className="h-full w-full bg-[#404040] overflow-hidden"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -3,27 +3,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
|
||||
interface NodeHealthGridProps {
|
||||
nodes: ProxmoxNode[] | undefined
|
||||
vms: ProxmoxVM[] | undefined
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getStatusColor(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'bg-green-500' : 'bg-red-500'
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield } from 'lucide-reac
|
||||
interface QuickActionsProps {
|
||||
onRefresh: () => void
|
||||
isRefreshing: boolean
|
||||
onNavigate?: (view: string) => void
|
||||
}
|
||||
|
||||
export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -27,6 +28,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
onClick={() => onNavigate?.('dashboard')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-xs">Nodes</span>
|
||||
@@ -34,6 +36,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
onClick={() => onNavigate?.('vms')}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
<span className="text-xs">VMs</span>
|
||||
@@ -41,6 +44,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
onClick={() => onNavigate?.('storage')}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-xs">Storage</span>
|
||||
@@ -48,6 +52,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
onClick={() => onNavigate?.('tasks')}
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<span className="text-xs">Tasks</span>
|
||||
@@ -55,6 +60,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
onClick={() => onNavigate?.('backups')}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-xs">Backups</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface ResourceGaugeProps {
|
||||
label: string
|
||||
@@ -15,14 +16,6 @@ const iconMap = {
|
||||
disk: HardDrive,
|
||||
} as const
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatCores(cores: number): string {
|
||||
return `${cores.toFixed(1)} cores`
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
|
||||
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
|
||||
import { ActivityFeed } from '@/components/dashboard/ActivityFeed'
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface DashboardProps {
|
||||
connectionId: string
|
||||
onNavigate?: (view: string) => void
|
||||
}
|
||||
|
||||
export function Dashboard({ connectionId }: DashboardProps) {
|
||||
export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: nodes, isLoading: nodesLoading } = useNodes(connectionId)
|
||||
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
|
||||
const { data: tasks, isLoading: tasksLoading } = useTasks(connectionId)
|
||||
const { data: nodes, isLoading: nodesLoading, error: nodesError } = useNodes(connectionId)
|
||||
const { data: vms, isLoading: vmsLoading, error: vmsError } = useVMs(connectionId)
|
||||
const { data: tasks, isLoading: tasksLoading, error: tasksError } = useTasks(connectionId)
|
||||
|
||||
const totalCPU = nodes?.reduce((acc, n) => acc + n.maxcpu, 0) ?? 0
|
||||
const usedCPU = nodes?.reduce((acc, n) => acc + n.cpu * n.maxcpu, 0) ?? 0
|
||||
@@ -25,14 +27,6 @@ export function Dashboard({ connectionId }: DashboardProps) {
|
||||
const totalDisk = nodes?.reduce((acc, n) => acc + n.maxdisk, 0) ?? 0
|
||||
const usedDisk = nodes?.reduce((acc, n) => acc + n.disk, 0) ?? 0
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -49,6 +43,26 @@ export function Dashboard({ connectionId }: DashboardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const hasError = nodesError || vmsError || tasksError
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Failed to Load Dashboard</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
@@ -152,7 +166,7 @@ export function Dashboard({ connectionId }: DashboardProps) {
|
||||
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} />
|
||||
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} onNavigate={onNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,14 +60,14 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem icon={Server} label="Nodes" />
|
||||
<SidebarItem icon={Server} label="Nodes" disabled />
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem icon={Box} label="Containers" />
|
||||
<SidebarItem icon={Box} label="Containers" disabled />
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
@@ -101,19 +101,24 @@ function SidebarItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
disabled
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
||||
)}
|
||||
|
||||
@@ -3,32 +3,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxNode } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
|
||||
interface NodeDetailProps {
|
||||
node: ProxmoxNode
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
label: string
|
||||
used: number
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { HardDrive, Database, Archive, Disc, Box } from 'lucide-react'
|
||||
import type { ProxmoxStorage } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface StorageCardProps {
|
||||
storage: ProxmoxStorage
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
|
||||
@@ -3,21 +3,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, HardDrive, Database, Archive, Disc, Box, Clock, File } from 'lucide-react'
|
||||
import { useStorageDetail, useStorageContent } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxStorageContent } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface StorageDetailProps {
|
||||
connectionId: string
|
||||
storage: string
|
||||
node: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
@@ -68,10 +62,10 @@ function formatContentItem(item: ProxmoxStorageContent): string {
|
||||
return parts[parts.length - 1] || item.volid
|
||||
}
|
||||
|
||||
export function StorageDetail({ connectionId, storage, onBack }: StorageDetailProps) {
|
||||
export function StorageDetail({ connectionId, storage, node, onBack }: StorageDetailProps) {
|
||||
const { data: detail, isLoading: detailLoading } = useStorageDetail(
|
||||
connectionId,
|
||||
null,
|
||||
node,
|
||||
storage
|
||||
)
|
||||
const { data: contentList, isLoading: contentLoading } = useStorageContent(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
|
||||
|
||||
interface StorageOverviewProps {
|
||||
connectionId: string
|
||||
onStorageClick?: (storage: string) => void
|
||||
onStorageClick?: (storage: string, node: string) => void
|
||||
}
|
||||
|
||||
export function StorageOverview({ connectionId, onStorageClick }: StorageOverviewProps) {
|
||||
@@ -79,7 +79,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
<StorageCard
|
||||
key={`${storage.node}-${storage.storage}`}
|
||||
storage={storage}
|
||||
onClick={() => onStorageClick?.(storage.storage)}
|
||||
onClick={() => onStorageClick?.(storage.storage, storage.node)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,32 +5,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Search, Server, Box } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
|
||||
interface ContainerListProps {
|
||||
connectionId: string
|
||||
onContainerClick?: (container: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
type ContainerStatusFilter = 'all' | ProxmoxVM['status']
|
||||
|
||||
export function ContainerList({ connectionId, onContainerClick }: ContainerListProps) {
|
||||
|
||||
@@ -5,32 +5,13 @@ import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Search, Server, Box } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
|
||||
interface VMListProps {
|
||||
connectionId: string
|
||||
onVMClick?: (vm: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
type VMTypeFilter = 'all' | 'qemu' | 'lxc'
|
||||
type VMStatusFilter = 'all' | ProxmoxVM['status']
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { useResizeDisk } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface ResizeDiskDialogProps {
|
||||
open: boolean
|
||||
@@ -20,14 +21,6 @@ interface ResizeDiskDialogProps {
|
||||
disk: ProxmoxDisk
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function ResizeDiskDialog({ open, onOpenChange, vm, disk }: ResizeDiskDialogProps) {
|
||||
const currentSizeGB = Math.round(disk.size / (1024 * 1024 * 1024))
|
||||
const [newSize, setNewSize] = useState(String(currentSizeGB))
|
||||
|
||||
@@ -39,6 +39,10 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
setErrorMessage(message)
|
||||
}, [])
|
||||
|
||||
const handleConnected = useCallback(() => {
|
||||
setStatus('connected')
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setErrorMessage(null)
|
||||
@@ -226,6 +230,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
node={vm.node}
|
||||
vmid={vm.vmid}
|
||||
onError={handleError}
|
||||
onConnected={handleConnected}
|
||||
/>
|
||||
) : (
|
||||
<TerminalConsole
|
||||
@@ -233,6 +238,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
node={vm.node}
|
||||
vmid={vm.vmid}
|
||||
onError={handleError}
|
||||
onConnected={handleConnected}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,20 +15,13 @@ import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog'
|
||||
import { ResizeDiskDialog } from '@/components/vms/dialogs/ResizeDiskDialog'
|
||||
import { MoveDiskDialog } from '@/components/vms/dialogs/MoveDiskDialog'
|
||||
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface DisksTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
const { data: disks, isLoading, error } = useDisks(connectionId, vm.node, vm.vmid)
|
||||
const removeDisk = useRemoveDisk()
|
||||
|
||||
@@ -11,24 +11,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Cpu, MemoryStick, Pencil, Save, X } from 'lucide-react'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
interface HardwareTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const { addToast } = useToast()
|
||||
const [formData, setFormData] = useState({
|
||||
name: vm.name,
|
||||
description: vm.tags ?? '',
|
||||
@@ -37,7 +32,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: Wire up to backend save mutation
|
||||
addToast('VM configuration editing is not yet implemented', 'warning')
|
||||
setEditOpen(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,20 +14,13 @@ import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox'
|
||||
import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog'
|
||||
import { EditNICDialog } from '@/components/vms/dialogs/EditNICDialog'
|
||||
import type { ProxmoxVM, ProxmoxNetwork } from '@/types/proxmox'
|
||||
import { formatNetworkRate } from '@/lib/format'
|
||||
|
||||
interface NetworkTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatNetworkRate(bytes: number): string {
|
||||
if (bytes === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid)
|
||||
const removeNIC = useRemoveNIC()
|
||||
|
||||
@@ -1,40 +1,13 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server, Globe, Tag } from 'lucide-react'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime, formatNetworkRate } from '@/lib/format'
|
||||
|
||||
interface OverviewTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
function formatNetworkRate(bytes: number): string {
|
||||
if (bytes === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
label: string
|
||||
used: number
|
||||
|
||||
+255
-105
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '@/lib/tauri'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import type { AddDiskConfig, AddNICConfig, EditNICConfig, CreateSnapshotConfig, BackupJobConfig, RestoreConfig } from '@/types/proxmox'
|
||||
|
||||
// Query keys
|
||||
@@ -88,91 +89,127 @@ export const useClusterStatus = (connectionId: string | null) => {
|
||||
// Mutations for VM lifecycle
|
||||
export const useStartVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.startVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.startVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to start VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useStopVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.stopVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.stopVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to stop VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useShutdownVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.shutdownVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.shutdownVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to shutdown VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRebootVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.rebootVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.rebootVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to reboot VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useSuspendVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.suspendVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.suspendVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to suspend VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useResumeVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) =>
|
||||
api.resumeVM(activeConnectionId!, node, vmid),
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.resumeVM(connId, node, vmid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to resume VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,7 +224,7 @@ export const useDisks = (connectionId: string, node: string, vmid: number) => {
|
||||
|
||||
export const useAddDisk = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -198,20 +235,27 @@ export const useAddDisk = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
config: AddDiskConfig
|
||||
}) => api.addDisk(activeConnectionId!, node, vmid, config),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.addDisk(connId, node, vmid, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to add disk', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useResizeDisk = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -224,20 +268,27 @@ export const useResizeDisk = () => {
|
||||
vmid: number
|
||||
disk: string
|
||||
size: number
|
||||
}) => api.resizeDisk(activeConnectionId!, node, vmid, disk, size),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.resizeDisk(connId, node, vmid, disk, size)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to resize disk', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRemoveDisk = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -248,20 +299,27 @@ export const useRemoveDisk = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
disk: string
|
||||
}) => api.removeDisk(activeConnectionId!, node, vmid, disk),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.removeDisk(connId, node, vmid, disk)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to remove disk', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useMoveDisk = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -274,14 +332,21 @@ export const useMoveDisk = () => {
|
||||
vmid: number
|
||||
disk: string
|
||||
storage: string
|
||||
}) => api.moveDisk(activeConnectionId!, node, vmid, disk, storage),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.moveDisk(connId, node, vmid, disk, storage)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.disks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.disks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to move disk', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,7 +361,7 @@ export const useNetworkInterfaces = (connectionId: string, node: string, vmid: n
|
||||
|
||||
export const useAddNIC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -307,20 +372,27 @@ export const useAddNIC = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
config: AddNICConfig
|
||||
}) => api.addNIC(activeConnectionId!, node, vmid, config),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.addNIC(connId, node, vmid, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to add NIC', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useEditNIC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -333,20 +405,27 @@ export const useEditNIC = () => {
|
||||
vmid: number
|
||||
nic: string
|
||||
config: EditNICConfig
|
||||
}) => api.editNIC(activeConnectionId!, node, vmid, nic, config),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.editNIC(connId, node, vmid, nic, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to edit NIC', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRemoveNIC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -357,14 +436,21 @@ export const useRemoveNIC = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
nic: string
|
||||
}) => api.removeNIC(activeConnectionId!, node, vmid, nic),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.removeNIC(connId, node, vmid, nic)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.networks(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.networks(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to remove NIC', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -379,7 +465,7 @@ export const useSnapshots = (connectionId: string, node: string, vmid: number) =
|
||||
|
||||
export const useCreateSnapshot = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -390,20 +476,27 @@ export const useCreateSnapshot = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
config: CreateSnapshotConfig
|
||||
}) => api.createSnapshot(activeConnectionId!, node, vmid, config),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.createSnapshot(connId, node, vmid, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to create snapshot', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteSnapshot = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -414,20 +507,27 @@ export const useDeleteSnapshot = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
name: string
|
||||
}) => api.deleteSnapshot(activeConnectionId!, node, vmid, name),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deleteSnapshot(connId, node, vmid, name)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to delete snapshot', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRollbackSnapshot = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -438,21 +538,28 @@ export const useRollbackSnapshot = () => {
|
||||
node: string
|
||||
vmid: number
|
||||
name: string
|
||||
}) => api.rollbackSnapshot(activeConnectionId!, node, vmid, name),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.rollbackSnapshot(connId, node, vmid, name)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (activeConnectionId) {
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.snapshots(activeConnectionId, variables.node, variables.vmid),
|
||||
queryKey: queryKeys.snapshots(connId, variables.node, variables.vmid),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to rollback snapshot', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// VM migration hooks
|
||||
export const useMigrateVM = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -465,12 +572,19 @@ export const useMigrateVM = () => {
|
||||
vmid: number
|
||||
targetNode: string
|
||||
online: boolean
|
||||
}) => api.migrateVM(activeConnectionId!, node, vmid, targetNode, online),
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.migrateVM(connId, node, vmid, targetNode, online)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to migrate VM', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -512,91 +626,127 @@ export const useBackups = (connectionId: string | null, storage?: string) => {
|
||||
|
||||
export const useCreateBackupJob = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ config }: { config: BackupJobConfig }) =>
|
||||
api.createBackupJob(activeConnectionId!, config),
|
||||
mutationFn: ({ config }: { config: BackupJobConfig }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.createBackupJob(connId, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to create backup job', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateBackupJob = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, config }: { id: string; config: BackupJobConfig }) =>
|
||||
api.updateBackupJob(activeConnectionId!, id, config),
|
||||
mutationFn: ({ id, config }: { id: string; config: BackupJobConfig }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.updateBackupJob(connId, id, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to update backup job', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteBackupJob = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
api.deleteBackupJob(activeConnectionId!, id),
|
||||
mutationFn: ({ id }: { id: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deleteBackupJob(connId, id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to delete backup job', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRunBackup = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ config }: { config: BackupJobConfig }) =>
|
||||
api.runBackup(activeConnectionId!, config),
|
||||
mutationFn: ({ config }: { config: BackupJobConfig }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.runBackup(connId, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to run backup', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRestoreBackup = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ volid, config }: { volid: string; config: RestoreConfig }) =>
|
||||
api.restoreBackup(activeConnectionId!, volid, config),
|
||||
mutationFn: ({ volid, config }: { volid: string; config: RestoreConfig }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.restoreBackup(connId, volid, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(activeConnectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to restore backup', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteBackup = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ volid }: { volid: string }) =>
|
||||
api.deleteBackup(activeConnectionId!, volid),
|
||||
mutationFn: ({ volid }: { volid: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deleteBackup(connId, volid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (activeConnectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backups(activeConnectionId) })
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) })
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast(error.message || 'Failed to delete backup', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
|
||||
useEffect(() => {
|
||||
if (!connectionId) return
|
||||
|
||||
let cancelled = false
|
||||
let unlistenFns: Array<() => void> = []
|
||||
|
||||
const setupListeners = async () => {
|
||||
@@ -51,7 +52,7 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
|
||||
}
|
||||
})
|
||||
unlistenFns.push(unlistenTask)
|
||||
if (!cancelled) unlistenFns.push(unlistenTask)
|
||||
|
||||
const unlistenNode = await listen('node-status-change', (event: { payload: { connection_id: string } }) => {
|
||||
if (event.payload.connection_id === connectionId) {
|
||||
@@ -59,19 +60,22 @@ export const useWebSocket = (connectionId: string | null): UseWebSocketReturn =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.cluster(connectionId) })
|
||||
}
|
||||
})
|
||||
unlistenFns.push(unlistenNode)
|
||||
if (!cancelled) unlistenFns.push(unlistenNode)
|
||||
|
||||
const unlistenVM = await listen('vm-status-change', (event: { payload: { connection_id: string } }) => {
|
||||
if (event.payload.connection_id === connectionId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
|
||||
}
|
||||
})
|
||||
unlistenFns.push(unlistenVM)
|
||||
if (!cancelled) unlistenFns.push(unlistenVM)
|
||||
}
|
||||
|
||||
setupListeners()
|
||||
setupListeners().catch((err) => {
|
||||
console.error('[useWebSocket] Failed to setup listeners:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unlistenFns.forEach((fn) => fn())
|
||||
unlistenFns = []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Format bytes into human-readable string (e.g., "1.5 GB").
|
||||
* Handles negative values by clamping to 0.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Proxmox uptime (seconds) into a human-readable string.
|
||||
*/
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format network rate (bytes/s) into human-readable string.
|
||||
*/
|
||||
export function formatNetworkRate(bytesPerSec: number): string {
|
||||
if (bytesPerSec === 0) return '0 B/s'
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
let i = 0
|
||||
let val = bytesPerSec
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024
|
||||
i++
|
||||
}
|
||||
return `${val.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
||||
target: 'es2022',
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user