feat: add PBS datastore management and harden macOS keychain storage
Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests. Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Clustri
|
# Clustri
|
||||||
|
|
||||||
A cross-platform desktop client for managing Proxmox VE servers and clusters. The backend is Rust on Tauri 2; the UI is React 19 with TypeScript, Vite, and Tailwind CSS v4.
|
A cross-platform desktop client for managing Proxmox VE servers and clusters, and Proxmox Backup Server. The backend is Rust on Tauri 2; the UI is React 19 with TypeScript, Vite, and Tailwind CSS v4.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -45,6 +45,15 @@ A cross-platform desktop client for managing Proxmox VE servers and clusters. Th
|
|||||||
- Snapshot list, create, delete, rollback.
|
- Snapshot list, create, delete, rollback.
|
||||||
- Backup jobs: list, create, edit, delete, run now. Restore or delete existing backups.
|
- Backup jobs: list, create, edit, delete, run now. Restore or delete existing backups.
|
||||||
|
|
||||||
|
### Backup server (PBS)
|
||||||
|
|
||||||
|
- Connect to a Proxmox Backup Server (port 8007) as its own connection type.
|
||||||
|
- Datastore overview with usage, and a backup groups → snapshot drill-down.
|
||||||
|
- Download individual snapshot archives (raw or decoded) via the OS save dialog; delete snapshots and backup groups.
|
||||||
|
- One-shot verify, prune (retention rules plus dry-run), and garbage collection.
|
||||||
|
- Read-only lists of scheduled verify, prune, and garbage-collection jobs, plus the task list.
|
||||||
|
- Whole-VM/CT restore is performed through Proxmox VE, not PBS.
|
||||||
|
|
||||||
### Console
|
### Console
|
||||||
|
|
||||||
- noVNC for VMs and an xterm.js terminal for containers, with fullscreen and Ctrl+Alt+Del support.
|
- noVNC for VMs and an xterm.js terminal for containers, with fullscreen and Ctrl+Alt+Del support.
|
||||||
@@ -103,6 +112,10 @@ The same core routes each request through an ordered, deduplicated endpoint list
|
|||||||
|
|
||||||
Tauri commands cover: connection add/remove/update/load, connect/disconnect, set active; password and token login, logout, stored credentials; certificate info and trust; nodes, VMs, storage, storage content and detail, tasks, cluster status; QEMU and LXC lifecycle and migration; disks, NICs, snapshots; VNC and terminal proxies; websocket URL; backup jobs and restore; and the tray menu.
|
Tauri commands cover: connection add/remove/update/load, connect/disconnect, set active; password and token login, logout, stored credentials; certificate info and trust; nodes, VMs, storage, storage content and detail, tasks, cluster status; QEMU and LXC lifecycle and migration; disks, NICs, snapshots; VNC and terminal proxies; websocket URL; backup jobs and restore; and the tray menu.
|
||||||
|
|
||||||
|
### Backup server (PBS)
|
||||||
|
|
||||||
|
PBS connections reuse the same ConnectionManager/request core with server-type-aware auth headers (`PBSAPIToken`/`PBSAuthCookie` vs `PVEAPIToken`/`PVEAuthCookie`). PBS is single-host — there is no node discovery or cluster merge. The PBS module lives in `src-tauri/src/pbs.rs`. Tasks flow through the shared task list via `GET /nodes/localhost/tasks`, and snapshot archives download through the raw `/download` and `/download-decoded` endpoints.
|
||||||
|
|
||||||
### TLS
|
### TLS
|
||||||
|
|
||||||
Proxmox nodes typically run self-signed certificates, so the transport accepts them and the app enforces trust itself. On first connect the certificate is captured in `src-tauri/src/tls.rs`, its SHA-256 fingerprint is shown for confirmation, and the fingerprint is pinned and checked on every later connect.
|
Proxmox nodes typically run self-signed certificates, so the transport accepts them and the app enforces trust itself. On first connect the certificate is captured in `src-tauri/src/tls.rs`, its SHA-256 fingerprint is shown for confirmation, and the fingerprint is pinned and checked on every later connect.
|
||||||
@@ -239,6 +252,12 @@ Cluster discovery, failover, and same-cluster merging are covered by Rust tests
|
|||||||
5. Uncheck Privilege Separation for full access.
|
5. Uncheck Privilege Separation for full access.
|
||||||
6. Copy the token. It looks like `user@realm!tokenid=secret`.
|
6. Copy the token. It looks like `user@realm!tokenid=secret`.
|
||||||
|
|
||||||
|
### Proxmox Backup Server API token
|
||||||
|
|
||||||
|
1. In the PBS web UI, go to Server Administrator → Access Control → API Tokens and create a token.
|
||||||
|
2. It uses the same `user@realm!tokenid=secret` format as the Proxmox VE token.
|
||||||
|
3. Connect to the PBS server as `https://host:8007`.
|
||||||
|
|
||||||
### Connection settings
|
### Connection settings
|
||||||
|
|
||||||
When adding a connection you need:
|
When adding a connection you need:
|
||||||
|
|||||||
Generated
+10
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
@@ -2941,6 +2942,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.16",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
|
|||||||
Generated
+131
-37
@@ -47,6 +47,17 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "apple-native-keyring-store"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
|
||||||
|
dependencies = [
|
||||||
|
"keyring-core",
|
||||||
|
"log",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert-json-diff"
|
name = "assert-json-diff"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@@ -415,20 +426,25 @@ dependencies = [
|
|||||||
name = "clustri"
|
name = "clustri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"apple-native-keyring-store",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http",
|
||||||
"httpmock",
|
"httpmock",
|
||||||
"keyring",
|
"keyring-core",
|
||||||
|
"linux-keyutils-keyring-store",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -436,6 +452,7 @@ dependencies = [
|
|||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows-native-keyring-store",
|
||||||
"x509-cert",
|
"x509-cert",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -474,16 +491,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "core-foundation"
|
|
||||||
version = "0.9.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -507,7 +514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -520,7 +527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1936,18 +1943,12 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyring"
|
name = "keyring-core"
|
||||||
version = "3.6.3"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
|
||||||
"linux-keyutils",
|
|
||||||
"log",
|
"log",
|
||||||
"security-framework 2.11.1",
|
|
||||||
"security-framework 3.7.0",
|
|
||||||
"windows-sys 0.60.2",
|
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2018,6 +2019,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-keyutils-keyring-store"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b"
|
||||||
|
dependencies = [
|
||||||
|
"keyring-core",
|
||||||
|
"linux-keyutils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2315,6 +2326,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2985,6 +2997,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3152,19 +3188,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "security-framework"
|
|
||||||
version = "2.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"core-foundation 0.9.4",
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
"security-framework-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -3172,7 +3195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -3674,7 +3697,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3836,6 +3859,64 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"toml 1.1.4+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -4916,6 +4997,19 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-native-keyring-store"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"keyring-core",
|
||||||
|
"regex",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-numerics"
|
name = "windows-numerics"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
+13
-1
@@ -10,12 +10,13 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
keyring-core = "1"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
@@ -24,6 +25,7 @@ x509-cert = "0.2"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
|
http = "1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
url = "2"
|
url = "2"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
@@ -33,6 +35,16 @@ httpmock = "0.8"
|
|||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
apple-native-keyring-store = { version = "1", features = ["keychain"] }
|
||||||
|
security-framework = "3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-native-keyring-store = "1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
linux-keyutils-keyring-store = "1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-is-visible"
|
"core:window:allow-is-visible",
|
||||||
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible"]}}
|
{"default":{"identifier":"default","description":"Default capabilities for Clustri","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible","dialog:default"]}}
|
||||||
@@ -2191,6 +2191,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2191,6 +2191,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+390
-221
@@ -1,4 +1,8 @@
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
use crate::keyring::{
|
||||||
|
delete_credential as keyring_delete_credential, describe_error as keyring_describe_error,
|
||||||
|
entry as keyring_entry, keyring, set_password as keyring_set_password,
|
||||||
|
};
|
||||||
use crate::proxmox::{
|
use crate::proxmox::{
|
||||||
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus,
|
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus,
|
||||||
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
||||||
@@ -75,16 +79,57 @@ pub enum AuthMode {
|
|||||||
Password,
|
Password,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The kind of Proxmox server a connection targets. The two platforms share
|
||||||
|
/// the JSON API shape and the ticket/token authentication model but use
|
||||||
|
/// different header names and login endpoints.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ServerType {
|
||||||
|
Pve,
|
||||||
|
Pbs,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerType {
|
||||||
|
/// Maps a connection's `serverType` string to a [`ServerType`]. Any value
|
||||||
|
/// other than `"pbs"` is treated as PVE.
|
||||||
|
pub(crate) fn from_config(s: &str) -> ServerType {
|
||||||
|
if s == "pbs" {
|
||||||
|
ServerType::Pbs
|
||||||
|
} else {
|
||||||
|
ServerType::Pve
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Authorization` header value prefix for token authentication
|
||||||
|
/// (`PVEAPIToken` for VE, `PBSAPIToken` for PBS).
|
||||||
|
pub(crate) fn token_header_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ServerType::Pve => "PVEAPIToken",
|
||||||
|
ServerType::Pbs => "PBSAPIToken",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Cookie` header value prefix for ticket authentication
|
||||||
|
/// (`PVEAuthCookie` for VE, `PBSAuthCookie` for PBS).
|
||||||
|
pub(crate) fn cookie_header_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ServerType::Pve => "PVEAuthCookie",
|
||||||
|
ServerType::Pbs => "PBSAuthCookie",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Authentication material for a Proxmox API request.
|
/// Authentication material for a Proxmox API request.
|
||||||
///
|
///
|
||||||
/// Token mode uses `token` as a `PVEAPIToken` header; password mode uses
|
/// Token mode uses `token` as a `PVEAPIToken`/`PBSAPIToken` header; password
|
||||||
/// `ticket` as a `PVEAuthCookie` (plus `csrf_token` for non-GET requests).
|
/// mode uses `ticket` as a `PVEAuthCookie`/`PBSAuthCookie` (plus `csrf_token`
|
||||||
|
/// for non-GET requests). The header names are chosen from `server_type`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthContext {
|
pub struct AuthContext {
|
||||||
pub mode: AuthMode,
|
pub mode: AuthMode,
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
pub ticket: Option<String>,
|
pub ticket: Option<String>,
|
||||||
pub csrf_token: Option<String>,
|
pub csrf_token: Option<String>,
|
||||||
|
pub server_type: ServerType,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the full Proxmox API URL for a request.
|
/// Builds the full Proxmox API URL for a request.
|
||||||
@@ -101,7 +146,7 @@ fn build_api_url(base_url: &str, path: &str) -> crate::Result<Url> {
|
|||||||
/// Deserializes an API response payload into `T`, mapping a parse failure to a
|
/// Deserializes an API response payload into `T`, mapping a parse failure to a
|
||||||
/// [`crate::Error::SerializationError`] that names the endpoint so the
|
/// [`crate::Error::SerializationError`] that names the endpoint so the
|
||||||
/// mismatch is easy to diagnose.
|
/// mismatch is easy to diagnose.
|
||||||
fn parse_api<T>(endpoint: &str, data: serde_json::Value) -> crate::Result<T>
|
pub(crate) fn parse_api<T>(endpoint: &str, data: serde_json::Value) -> crate::Result<T>
|
||||||
where
|
where
|
||||||
T: serde::de::DeserializeOwned,
|
T: serde::de::DeserializeOwned,
|
||||||
{
|
{
|
||||||
@@ -160,29 +205,7 @@ pub async fn api_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut request = client.request(method, url);
|
let mut request = client.request(method, url);
|
||||||
match auth.mode {
|
request = apply_auth_headers(request, auth, needs_csrf)?;
|
||||||
AuthMode::Token => {
|
|
||||||
let token = auth
|
|
||||||
.token
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| Error::InvalidCredentials("No API token configured".to_string()))?;
|
|
||||||
request = request.header("Authorization", format!("PVEAPIToken={}", token));
|
|
||||||
}
|
|
||||||
AuthMode::Password => {
|
|
||||||
let ticket = auth
|
|
||||||
.ticket
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?;
|
|
||||||
request = request.header("Cookie", format!("PVEAuthCookie={}", ticket));
|
|
||||||
if needs_csrf {
|
|
||||||
let csrf = auth
|
|
||||||
.csrf_token
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| Error::AuthError("Not logged in: no CSRF token".to_string()))?;
|
|
||||||
request = request.header("CSRFPreventionToken", csrf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(fields) = form {
|
if let Some(fields) = form {
|
||||||
request = request.form(fields);
|
request = request.form(fields);
|
||||||
@@ -202,13 +225,7 @@ pub async fn api_request(
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await.map_err(Error::HttpError)?;
|
let text = response.text().await.map_err(Error::HttpError)?;
|
||||||
let body: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|_| {
|
let body = parse_body_text(&text);
|
||||||
if text.is_empty() {
|
|
||||||
serde_json::Value::Null
|
|
||||||
} else {
|
|
||||||
serde_json::Value::String(text)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let message = error_message_from_body(&body, status.as_u16());
|
let message = error_message_from_body(&body, status.as_u16());
|
||||||
@@ -218,6 +235,63 @@ pub async fn api_request(
|
|||||||
Ok(body.get("data").cloned().unwrap_or(body))
|
Ok(body.get("data").cloned().unwrap_or(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a raw response body into a JSON value, treating an empty body as
|
||||||
|
/// `Null` and a non-JSON body as a plain string. The standard
|
||||||
|
/// `{ "data": ... }` envelope is left intact; callers decide whether to
|
||||||
|
/// unwrap it.
|
||||||
|
fn parse_body_text(text: &str) -> serde_json::Value {
|
||||||
|
match serde_json::from_str(text) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) if text.is_empty() => serde_json::Value::Null,
|
||||||
|
Err(_) => serde_json::Value::String(text.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the authentication headers from `auth` to a request builder.
|
||||||
|
///
|
||||||
|
/// Token mode sets an `Authorization` header whose value prefix depends on the
|
||||||
|
/// server type (`PVEAPIToken`/`PBSAPIToken`). Password mode sets a `Cookie`
|
||||||
|
/// header (`PVEAuthCookie`/`PBSAuthCookie`) and, for non-GET requests, the
|
||||||
|
/// `CSRFPreventionToken` header. Missing secrets surface as
|
||||||
|
/// [`Error::InvalidCredentials`]/[`Error::AuthError`], matching the behavior
|
||||||
|
/// of the pre-refactor inline injection in `api_request`.
|
||||||
|
fn apply_auth_headers(
|
||||||
|
mut request: reqwest::RequestBuilder,
|
||||||
|
auth: &AuthContext,
|
||||||
|
needs_csrf: bool,
|
||||||
|
) -> crate::Result<reqwest::RequestBuilder> {
|
||||||
|
match auth.mode {
|
||||||
|
AuthMode::Token => {
|
||||||
|
let token = auth
|
||||||
|
.token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::InvalidCredentials("No API token configured".to_string()))?;
|
||||||
|
request = request.header(
|
||||||
|
"Authorization",
|
||||||
|
format!("{}={}", auth.server_type.token_header_name(), token),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
AuthMode::Password => {
|
||||||
|
let ticket = auth
|
||||||
|
.ticket
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::AuthError("Not logged in: no session ticket".to_string()))?;
|
||||||
|
request = request.header(
|
||||||
|
"Cookie",
|
||||||
|
format!("{}={}", auth.server_type.cookie_header_name(), ticket),
|
||||||
|
);
|
||||||
|
if needs_csrf {
|
||||||
|
let csrf = auth
|
||||||
|
.csrf_token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| Error::AuthError("Not logged in: no CSRF token".to_string()))?;
|
||||||
|
request = request.header("CSRFPreventionToken", csrf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the error message surfaced for a non-success API response.
|
/// Builds the error message surfaced for a non-success API response.
|
||||||
///
|
///
|
||||||
/// The `errors` field is preferred over the generic `message` (Proxmox
|
/// The `errors` field is preferred over the generic `message` (Proxmox
|
||||||
@@ -267,9 +341,9 @@ fn error_message_from_body(body: &serde_json::Value, status: u16) -> String {
|
|||||||
format!("Proxmox API error (HTTP {})", status)
|
format!("Proxmox API error (HTTP {})", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Connection {
|
pub(crate) struct Connection {
|
||||||
config: ConnectionConfig,
|
pub(crate) config: ConnectionConfig,
|
||||||
client: Client,
|
pub(crate) client: Client,
|
||||||
ticket: Mutex<Option<String>>,
|
ticket: Mutex<Option<String>>,
|
||||||
csrf_token: Mutex<Option<String>>,
|
csrf_token: Mutex<Option<String>>,
|
||||||
current_endpoint_index: Mutex<usize>,
|
current_endpoint_index: Mutex<usize>,
|
||||||
@@ -293,7 +367,8 @@ impl Connection {
|
|||||||
/// Token mode reads the token from the connection config, falling back to
|
/// Token mode reads the token from the connection config, falling back to
|
||||||
/// the keyring. Password mode uses the in-memory session, loading the
|
/// the keyring. Password mode uses the in-memory session, loading the
|
||||||
/// ticket/CSRF token from the keyring and caching them if absent.
|
/// ticket/CSRF token from the keyring and caching them if absent.
|
||||||
fn auth_context(&self) -> crate::Result<AuthContext> {
|
pub(crate) fn auth_context(&self) -> crate::Result<AuthContext> {
|
||||||
|
let server_type = ServerType::from_config(&self.config.server_type);
|
||||||
if self.config.auth_mode == "token" {
|
if self.config.auth_mode == "token" {
|
||||||
let token = match self.config.primary.token.as_deref() {
|
let token = match self.config.primary.token.as_deref() {
|
||||||
Some(token) if !token.is_empty() => Some(token.to_string()),
|
Some(token) if !token.is_empty() => Some(token.to_string()),
|
||||||
@@ -304,6 +379,7 @@ impl Connection {
|
|||||||
token,
|
token,
|
||||||
ticket: None,
|
ticket: None,
|
||||||
csrf_token: None,
|
csrf_token: None,
|
||||||
|
server_type,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
let ticket = self
|
let ticket = self
|
||||||
@@ -315,6 +391,7 @@ impl Connection {
|
|||||||
token: None,
|
token: None,
|
||||||
ticket: Some(ticket),
|
ticket: Some(ticket),
|
||||||
csrf_token,
|
csrf_token,
|
||||||
|
server_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +403,7 @@ impl Connection {
|
|||||||
Ok(entry) => match entry.get_password() {
|
Ok(entry) => match entry.get_password() {
|
||||||
Ok(value) => Ok(Some(value)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(Error::KeyringError(e.to_string())),
|
Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))),
|
||||||
},
|
},
|
||||||
Err(_) => Ok(None),
|
Err(_) => Ok(None),
|
||||||
}
|
}
|
||||||
@@ -357,7 +434,7 @@ impl Connection {
|
|||||||
/// Returns the ordered, deduplicated candidate endpoint URLs for failover:
|
/// Returns the ordered, deduplicated candidate endpoint URLs for failover:
|
||||||
/// the primary first, followed by each configured fallback. Empty URLs are
|
/// the primary first, followed by each configured fallback. Empty URLs are
|
||||||
/// dropped and duplicates are collapsed, preserving order.
|
/// dropped and duplicates are collapsed, preserving order.
|
||||||
fn endpoint_urls(&self) -> Vec<String> {
|
pub(crate) fn endpoint_urls(&self) -> Vec<String> {
|
||||||
let mut urls: Vec<String> = Vec::new();
|
let mut urls: Vec<String> = Vec::new();
|
||||||
let primary = self.config.primary.url.clone();
|
let primary = self.config.primary.url.clone();
|
||||||
let fallbacks = self
|
let fallbacks = self
|
||||||
@@ -376,7 +453,7 @@ impl Connection {
|
|||||||
|
|
||||||
/// Remembers the endpoint that last served a request, so the next request
|
/// Remembers the endpoint that last served a request, so the next request
|
||||||
/// resumes rotation there instead of re-testing a down primary.
|
/// resumes rotation there instead of re-testing a down primary.
|
||||||
fn set_endpoint_index(&self, idx: usize) {
|
pub(crate) fn set_endpoint_index(&self, idx: usize) {
|
||||||
if let Ok(mut guard) = self.current_endpoint_index.lock() {
|
if let Ok(mut guard) = self.current_endpoint_index.lock() {
|
||||||
*guard = idx;
|
*guard = idx;
|
||||||
}
|
}
|
||||||
@@ -384,7 +461,7 @@ impl Connection {
|
|||||||
|
|
||||||
/// Records the runtime status of the last request: `"connected"`,
|
/// Records the runtime status of the last request: `"connected"`,
|
||||||
/// `"failover"`, or `"failed"`.
|
/// `"failover"`, or `"failed"`.
|
||||||
fn set_runtime_status(&self, status: &str) {
|
pub(crate) fn set_runtime_status(&self, status: &str) {
|
||||||
if let Ok(mut guard) = self.runtime_status.lock() {
|
if let Ok(mut guard) = self.runtime_status.lock() {
|
||||||
*guard = status.to_string();
|
*guard = status.to_string();
|
||||||
}
|
}
|
||||||
@@ -406,7 +483,7 @@ impl Connection {
|
|||||||
/// (connection refused, timeouts, DNS resolution) trigger failover to the
|
/// (connection refused, timeouts, DNS resolution) trigger failover to the
|
||||||
/// next candidate; authentication and API errors are returned immediately
|
/// next candidate; authentication and API errors are returned immediately
|
||||||
/// without rotating.
|
/// without rotating.
|
||||||
async fn request(
|
pub(crate) async fn request(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -439,15 +516,95 @@ impl Connection {
|
|||||||
Err(last_transport_err
|
Err(last_transport_err
|
||||||
.unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string())))
|
.unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string())))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn keyring_service() -> &'static str {
|
/// Streams a GET response body (binary, no `{data}` envelope unwrap) to
|
||||||
"clustri"
|
/// `dest`.
|
||||||
}
|
///
|
||||||
|
/// Uses the same endpoint rotation and auth as `request()`: transport
|
||||||
|
/// failures rotate to the next candidate endpoint, while auth/API/other
|
||||||
|
/// errors propagate immediately. Returns the number of bytes written.
|
||||||
|
///
|
||||||
|
/// Used by the PBS backup-download endpoints, which stream raw archive
|
||||||
|
/// bytes rather than a JSON envelope.
|
||||||
|
pub(crate) async fn download_to_file(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, String)],
|
||||||
|
dest: &std::path::Path,
|
||||||
|
) -> crate::Result<u64> {
|
||||||
|
let auth = self.auth_context()?;
|
||||||
|
let candidates = self.endpoint_urls();
|
||||||
|
let start = *self
|
||||||
|
.current_endpoint_index
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut last_transport_err = None;
|
||||||
|
for offset in 0..candidates.len() {
|
||||||
|
let idx = (start + offset) % candidates.len();
|
||||||
|
let url = &candidates[idx];
|
||||||
|
match self
|
||||||
|
.download_from_endpoint(url, path, query, &auth, dest)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(bytes) => {
|
||||||
|
self.set_endpoint_index(idx);
|
||||||
|
self.set_runtime_status(if idx == 0 { "connected" } else { "failover" });
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
Err(Error::ConnectionFailed(message)) => {
|
||||||
|
last_transport_err = Some(Error::ConnectionFailed(message));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.set_runtime_status("failed");
|
||||||
|
Err(last_transport_err
|
||||||
|
.unwrap_or_else(|| Error::ConnectionFailed("no endpoints available".to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
fn keyring_entry(connection_id: &str, field: &str) -> crate::Result<keyring::Entry> {
|
/// Performs a single raw GET download against `base_url` and writes the
|
||||||
let key = format!("{}:{}", connection_id, field);
|
/// response body to `dest`. Auth headers mirror `api_request` (a GET needs
|
||||||
keyring::Entry::new(keyring_service(), &key).map_err(|e| Error::KeyringError(e.to_string()))
|
/// no CSRF header, but the cookie/authorization header is always set); a
|
||||||
|
/// non-success status is surfaced as [`Error::ApiError`].
|
||||||
|
async fn download_from_endpoint(
|
||||||
|
&self,
|
||||||
|
base_url: &str,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, String)],
|
||||||
|
auth: &AuthContext,
|
||||||
|
dest: &std::path::Path,
|
||||||
|
) -> crate::Result<u64> {
|
||||||
|
let mut url = build_api_url(base_url, path)?;
|
||||||
|
{
|
||||||
|
let mut pairs = url.query_pairs_mut();
|
||||||
|
for (key, value) in query {
|
||||||
|
pairs.append_pair(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = apply_auth_headers(self.client.get(url), auth, false)?;
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
|
if e.is_connect() || e.is_timeout() || e.is_request() {
|
||||||
|
// Transport-level failures are surfaced as `ConnectionFailed`
|
||||||
|
// so the caller can fail over to another endpoint.
|
||||||
|
Error::ConnectionFailed(format!("Cannot connect to server: {}", e))
|
||||||
|
} else {
|
||||||
|
Error::HttpError(e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = response.text().await.map_err(Error::HttpError)?;
|
||||||
|
let body = parse_body_text(&text);
|
||||||
|
let message = error_message_from_body(&body, status.as_u16());
|
||||||
|
return Err(Error::ApiError(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await.map_err(Error::HttpError)?;
|
||||||
|
tokio::fs::write(dest, &bytes).await?;
|
||||||
|
Ok(bytes.len() as u64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ConnectionManager {
|
pub struct ConnectionManager {
|
||||||
@@ -534,22 +691,10 @@ impl ConnectionManager {
|
|||||||
pub async fn remove_connection(&mut self, id: &str, path: &Path) -> crate::Result<()> {
|
pub async fn remove_connection(&mut self, id: &str, path: &Path) -> crate::Result<()> {
|
||||||
self.connections.remove(id);
|
self.connections.remove(id);
|
||||||
// Clear stored credentials from keyring
|
// Clear stored credentials from keyring
|
||||||
let _ = keyring_entry(id, "ticket").and_then(|e| {
|
let _ = keyring_delete_credential(id, "ticket");
|
||||||
e.delete_credential()
|
let _ = keyring_delete_credential(id, "csrf_token");
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
let _ = keyring_delete_credential(id, "password");
|
||||||
});
|
let _ = keyring_delete_credential(id, "token");
|
||||||
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()))
|
|
||||||
});
|
|
||||||
if self.active_connection_id.as_deref() == Some(id) {
|
if self.active_connection_id.as_deref() == Some(id) {
|
||||||
self.active_connection_id = None;
|
self.active_connection_id = None;
|
||||||
}
|
}
|
||||||
@@ -655,110 +800,116 @@ impl ConnectionManager {
|
|||||||
|
|
||||||
// Same-cluster merge: when this connection belongs to the same cluster
|
// Same-cluster merge: when this connection belongs to the same cluster
|
||||||
// as an existing one, fold its endpoint and node list into the
|
// as an existing one, fold its endpoint and node list into the
|
||||||
// existing connection and drop it.
|
// existing connection and drop it. PBS connections are single-host and
|
||||||
let cluster_id = {
|
// never participate in cluster merging.
|
||||||
|
let (cluster_id, server_type) = {
|
||||||
let conn = self
|
let conn = self
|
||||||
.connections
|
.connections
|
||||||
.get(id)
|
.get(id)
|
||||||
.expect("connection existence was checked above");
|
.expect("connection existence was checked above");
|
||||||
conn.config.cluster_id.clone()
|
(
|
||||||
|
conn.config.cluster_id.clone(),
|
||||||
|
conn.config.server_type.clone(),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(cid) = cluster_id.filter(|cid| !cid.is_empty()) {
|
if server_type != "pbs" {
|
||||||
let other_id = self
|
if let Some(cid) = cluster_id.filter(|cid| !cid.is_empty()) {
|
||||||
.connections
|
let other_id = self
|
||||||
.iter()
|
.connections
|
||||||
.find(|(other_id, conn)| {
|
.iter()
|
||||||
other_id.as_str() != id
|
.find(|(other_id, conn)| {
|
||||||
&& conn.config.cluster_id.as_deref() == Some(cid.as_str())
|
other_id.as_str() != id
|
||||||
})
|
&& conn.config.cluster_id.as_deref() == Some(cid.as_str())
|
||||||
.map(|(other_id, _)| other_id.clone());
|
})
|
||||||
|
.map(|(other_id, _)| other_id.clone());
|
||||||
|
|
||||||
if let Some(other_id) = other_id {
|
if let Some(other_id) = other_id {
|
||||||
let (this_primary_url, this_primary_node, this_nodes) = {
|
let (this_primary_url, this_primary_node, this_nodes) = {
|
||||||
let conn = self
|
let conn = self
|
||||||
.connections
|
.connections
|
||||||
.get(id)
|
.get(id)
|
||||||
.expect("connection existence was checked above");
|
.expect("connection existence was checked above");
|
||||||
(
|
(
|
||||||
conn.config.primary.url.clone(),
|
conn.config.primary.url.clone(),
|
||||||
conn.config.primary.node.clone(),
|
conn.config.primary.node.clone(),
|
||||||
conn.config.nodes.clone(),
|
conn.config.nodes.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
|
||||||
let other = self
|
|
||||||
.connections
|
|
||||||
.get_mut(&other_id)
|
|
||||||
.expect("merge target was located above");
|
|
||||||
// This connection's primary endpoint becomes a fallback on
|
|
||||||
// the surviving connection, deduplicated case-insensitively.
|
|
||||||
let url_known = other
|
|
||||||
.config
|
|
||||||
.fallbacks
|
|
||||||
.iter()
|
|
||||||
.any(|endpoint| endpoint.url.eq_ignore_ascii_case(&this_primary_url));
|
|
||||||
if !url_known {
|
|
||||||
other.config.fallbacks.push(EndpointConfig {
|
|
||||||
url: this_primary_url.clone(),
|
|
||||||
node: this_primary_node.clone(),
|
|
||||||
token: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Adopt the merging connection's primary node only when the
|
|
||||||
// surviving connection has none yet.
|
|
||||||
if other
|
|
||||||
.config
|
|
||||||
.primary
|
|
||||||
.node
|
|
||||||
.as_deref()
|
|
||||||
.map_or(true, str::is_empty)
|
|
||||||
{
|
{
|
||||||
other.config.primary.node = this_primary_node;
|
let other = self
|
||||||
}
|
.connections
|
||||||
// Merge the node lists (dedup by URL), then re-derive each
|
.get_mut(&other_id)
|
||||||
// node's primary marker against the surviving connection's
|
.expect("merge target was located above");
|
||||||
// primary URL.
|
// This connection's primary endpoint becomes a fallback on
|
||||||
let other_primary_url = other.config.primary.url.clone();
|
// the surviving connection, deduplicated case-insensitively.
|
||||||
for node in this_nodes {
|
let url_known = other
|
||||||
if !other
|
|
||||||
.config
|
.config
|
||||||
.nodes
|
.fallbacks
|
||||||
.iter()
|
.iter()
|
||||||
.any(|existing| existing.url.eq_ignore_ascii_case(&node.url))
|
.any(|endpoint| endpoint.url.eq_ignore_ascii_case(&this_primary_url));
|
||||||
|
if !url_known {
|
||||||
|
other.config.fallbacks.push(EndpointConfig {
|
||||||
|
url: this_primary_url.clone(),
|
||||||
|
node: this_primary_node.clone(),
|
||||||
|
token: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Adopt the merging connection's primary node only when the
|
||||||
|
// surviving connection has none yet.
|
||||||
|
if other
|
||||||
|
.config
|
||||||
|
.primary
|
||||||
|
.node
|
||||||
|
.as_deref()
|
||||||
|
.map_or(true, str::is_empty)
|
||||||
{
|
{
|
||||||
other.config.nodes.push(node);
|
other.config.primary.node = this_primary_node;
|
||||||
|
}
|
||||||
|
// Merge the node lists (dedup by URL), then re-derive each
|
||||||
|
// node's primary marker against the surviving connection's
|
||||||
|
// primary URL.
|
||||||
|
let other_primary_url = other.config.primary.url.clone();
|
||||||
|
for node in this_nodes {
|
||||||
|
if !other
|
||||||
|
.config
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.any(|existing| existing.url.eq_ignore_ascii_case(&node.url))
|
||||||
|
{
|
||||||
|
other.config.nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for node in &mut other.config.nodes {
|
||||||
|
node.is_primary = node.url.eq_ignore_ascii_case(&other_primary_url);
|
||||||
|
}
|
||||||
|
if other
|
||||||
|
.config
|
||||||
|
.primary
|
||||||
|
.node
|
||||||
|
.as_deref()
|
||||||
|
.map_or(true, str::is_empty)
|
||||||
|
{
|
||||||
|
if let Some(primary) =
|
||||||
|
other.config.nodes.iter().find(|node| node.is_primary)
|
||||||
|
{
|
||||||
|
other.config.primary.node = Some(primary.name.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for node in &mut other.config.nodes {
|
|
||||||
node.is_primary = node.url.eq_ignore_ascii_case(&other_primary_url);
|
|
||||||
}
|
|
||||||
if other
|
|
||||||
.config
|
|
||||||
.primary
|
|
||||||
.node
|
|
||||||
.as_deref()
|
|
||||||
.map_or(true, str::is_empty)
|
|
||||||
{
|
|
||||||
if let Some(primary) =
|
|
||||||
other.config.nodes.iter().find(|node| node.is_primary)
|
|
||||||
{
|
|
||||||
other.config.primary.node = Some(primary.name.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.connections.remove(id);
|
self.connections.remove(id);
|
||||||
if self.active_connection_id.as_deref() == Some(id) {
|
if self.active_connection_id.as_deref() == Some(id) {
|
||||||
self.active_connection_id = Some(other_id.clone());
|
self.active_connection_id = Some(other_id.clone());
|
||||||
|
}
|
||||||
|
self.persist(path)?;
|
||||||
|
return Ok(ConnectResult {
|
||||||
|
connection_id: other_id.clone(),
|
||||||
|
merged_into: Some(other_id),
|
||||||
|
status: "connected".to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
self.persist(path)?;
|
|
||||||
return Ok(ConnectResult {
|
|
||||||
connection_id: other_id.clone(),
|
|
||||||
merged_into: Some(other_id),
|
|
||||||
status: "connected".to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +951,11 @@ impl ConnectionManager {
|
|||||||
let conn = self.connection(id)?;
|
let conn = self.connection(id)?;
|
||||||
conn.set_endpoint_index(0);
|
conn.set_endpoint_index(0);
|
||||||
conn.set_runtime_status("connected");
|
conn.set_runtime_status("connected");
|
||||||
self.discover_nodes(id).await?;
|
// PBS is single-host: there is no node list or cluster identity to
|
||||||
|
// discover, so the discovery pass is skipped entirely.
|
||||||
|
if !conn.config.is_pbs() {
|
||||||
|
self.discover_nodes(id).await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,10 +1027,17 @@ impl ConnectionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let transport_failed = match self.discover_nodes(connection_id).await {
|
// PBS connections have no discoverable node list, so the status poll
|
||||||
Ok(_) => false,
|
// reports the snapshot and the current runtime status without any
|
||||||
Err(Error::ConnectionFailed(_)) => true,
|
// discovery request.
|
||||||
Err(_) => false,
|
let transport_failed = if self.connection(connection_id)?.config.is_pbs() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
match self.discover_nodes(connection_id).await {
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(Error::ConnectionFailed(_)) => true,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (primary_url, current_endpoint_url, nodes) = self.status_snapshot(connection_id)?;
|
let (primary_url, current_endpoint_url, nodes) = self.status_snapshot(connection_id)?;
|
||||||
@@ -1024,22 +1186,10 @@ impl ConnectionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Store credentials in keyring for later use
|
// Store credentials in keyring for later use
|
||||||
keyring_entry(&connection_id, "ticket").and_then(|e| {
|
keyring_set_password(&connection_id, "ticket", &ticket)?;
|
||||||
e.set_password(&ticket)
|
keyring_set_password(&connection_id, "csrf_token", &csrf_token)?;
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
keyring_set_password(&connection_id, "username", username)?;
|
||||||
})?;
|
keyring_set_password(&connection_id, "password", password)?;
|
||||||
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()))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Keep the in-memory session of an already-added connection in sync so
|
// Keep the in-memory session of an already-added connection in sync so
|
||||||
// requests made right after login have auth available.
|
// requests made right after login have auth available.
|
||||||
@@ -1054,7 +1204,12 @@ impl ConnectionManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login_with_token(&self, url: &str, token: &str) -> crate::Result<LoginResult> {
|
pub async fn login_with_token(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
token: &str,
|
||||||
|
server_type: &str,
|
||||||
|
) -> crate::Result<LoginResult> {
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
||||||
}
|
}
|
||||||
@@ -1066,9 +1221,16 @@ impl ConnectionManager {
|
|||||||
|
|
||||||
let client = build_client()?;
|
let client = build_client()?;
|
||||||
|
|
||||||
// Validate the token by making an authenticated request
|
// Validate the token by making an authenticated request. PVE exposes
|
||||||
let test_url = format!("{}/api2/json/cluster/status", url);
|
// `/cluster/status`; PBS (which has no cluster concept) validates
|
||||||
let auth_header = format!("PVEAPIToken={}", token);
|
// against `/version` instead. The header prefix follows the server
|
||||||
|
// type (`PVEAPIToken` vs `PBSAPIToken`).
|
||||||
|
let server_type = ServerType::from_config(server_type);
|
||||||
|
let test_url = match server_type {
|
||||||
|
ServerType::Pbs => format!("{}/api2/json/version", url),
|
||||||
|
ServerType::Pve => format!("{}/api2/json/cluster/status", url),
|
||||||
|
};
|
||||||
|
let auth_header = format!("{}={}", server_type.token_header_name(), token);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(&test_url)
|
.get(&test_url)
|
||||||
@@ -1097,10 +1259,7 @@ impl ConnectionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Store token in keyring for later use
|
// Store token in keyring for later use
|
||||||
keyring_entry(&connection_id, "token").and_then(|e| {
|
keyring_set_password(&connection_id, "token", token)?;
|
||||||
e.set_password(token)
|
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(LoginResult {
|
Ok(LoginResult {
|
||||||
connection_id,
|
connection_id,
|
||||||
@@ -1110,26 +1269,11 @@ impl ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(&self, connection_id: &str) -> crate::Result<()> {
|
pub async fn logout(&self, connection_id: &str) -> crate::Result<()> {
|
||||||
let _ = keyring_entry(connection_id, "ticket").and_then(|e| {
|
let _ = keyring_delete_credential(connection_id, "ticket");
|
||||||
e.delete_credential()
|
let _ = keyring_delete_credential(connection_id, "csrf_token");
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
let _ = keyring_delete_credential(connection_id, "password");
|
||||||
});
|
let _ = keyring_delete_credential(connection_id, "token");
|
||||||
let _ = keyring_entry(connection_id, "csrf_token").and_then(|e| {
|
let _ = keyring_delete_credential(connection_id, "username");
|
||||||
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,7 +1288,7 @@ impl ConnectionManager {
|
|||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(ticket) => Ok(Some(ticket)),
|
Ok(ticket) => Ok(Some(ticket)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(Error::KeyringError(e.to_string())),
|
Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,25 +1300,13 @@ impl ConnectionManager {
|
|||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
api_token: Option<&str>,
|
api_token: Option<&str>,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
keyring_entry(connection_id, "ticket").and_then(|e| {
|
keyring_set_password(connection_id, "ticket", ticket)?;
|
||||||
e.set_password(ticket)
|
keyring_set_password(connection_id, "csrf_token", csrf_token)?;
|
||||||
.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()))
|
|
||||||
})?;
|
|
||||||
if let Some(pw) = password {
|
if let Some(pw) = password {
|
||||||
keyring_entry(connection_id, "password").and_then(|e| {
|
keyring_set_password(connection_id, "password", pw)?;
|
||||||
e.set_password(pw)
|
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
if let Some(tok) = api_token {
|
if let Some(tok) = api_token {
|
||||||
keyring_entry(connection_id, "token").and_then(|e| {
|
keyring_set_password(connection_id, "token", tok)?;
|
||||||
e.set_password(tok)
|
|
||||||
.map_err(|e| Error::KeyringError(e.to_string()))
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1215,7 +1347,7 @@ impl ConnectionManager {
|
|||||||
Err(keyring::Error::NoEntry) => Err(Error::AuthError(
|
Err(keyring::Error::NoEntry) => Err(Error::AuthError(
|
||||||
"No stored credentials for re-authentication".to_string(),
|
"No stored credentials for re-authentication".to_string(),
|
||||||
)),
|
)),
|
||||||
Err(e) => Err(Error::KeyringError(e.to_string())),
|
Err(e) => Err(Error::KeyringError(keyring_describe_error(&e))),
|
||||||
},
|
},
|
||||||
Err(_) => Err(Error::AuthError(
|
Err(_) => Err(Error::AuthError(
|
||||||
"No stored credentials for re-authentication".to_string(),
|
"No stored credentials for re-authentication".to_string(),
|
||||||
@@ -1244,7 +1376,7 @@ impl ConnectionManager {
|
|||||||
|
|
||||||
/// Looks up a connection by id, mapping a missing id to
|
/// Looks up a connection by id, mapping a missing id to
|
||||||
/// [`Error::ConnectionNotFound`].
|
/// [`Error::ConnectionNotFound`].
|
||||||
fn connection(&self, id: &str) -> crate::Result<&Connection> {
|
pub(crate) fn connection(&self, id: &str) -> crate::Result<&Connection> {
|
||||||
self.connections
|
self.connections
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| Error::ConnectionNotFound(id.to_string()))
|
.ok_or_else(|| Error::ConnectionNotFound(id.to_string()))
|
||||||
@@ -1266,6 +1398,43 @@ impl ConnectionManager {
|
|||||||
Ok(conn.config.clone())
|
Ok(conn.config.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves the auth header to send alongside a WebSocket handshake for
|
||||||
|
/// `connection_id`, using the same secret resolution as `auth_context()`
|
||||||
|
/// (token from config/keyring; ticket from session/keyring).
|
||||||
|
///
|
||||||
|
/// Returns `Some((header_name, header_value))` when a secret is available
|
||||||
|
/// — `Authorization: {PVE|PBS}APIToken=...` for token mode, or
|
||||||
|
/// `Cookie: {PVE|PBS}AuthCookie=...` for password mode — and `None` when
|
||||||
|
/// no secret can be resolved.
|
||||||
|
pub fn auth_header_for(&self, connection_id: &str) -> crate::Result<Option<(String, String)>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let auth = conn.auth_context()?;
|
||||||
|
match auth.mode {
|
||||||
|
AuthMode::Token => {
|
||||||
|
Ok(auth
|
||||||
|
.token
|
||||||
|
.as_deref()
|
||||||
|
.filter(|token| !token.is_empty())
|
||||||
|
.map(|token| {
|
||||||
|
(
|
||||||
|
"Authorization".to_string(),
|
||||||
|
format!("{}={}", auth.server_type.token_header_name(), token),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
AuthMode::Password => Ok(auth
|
||||||
|
.ticket
|
||||||
|
.as_deref()
|
||||||
|
.filter(|ticket| !ticket.is_empty())
|
||||||
|
.map(|ticket| {
|
||||||
|
(
|
||||||
|
"Cookie".to_string(),
|
||||||
|
format!("{}={}", auth.server_type.cookie_header_name(), ticket),
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Validates that `vm_type` is one of the Proxmox VM type path segments
|
/// Validates that `vm_type` is one of the Proxmox VM type path segments
|
||||||
/// (`qemu` for VMs, `lxc` for containers).
|
/// (`qemu` for VMs, `lxc` for containers).
|
||||||
fn validate_vm_type(vm_type: &str) -> crate::Result<()> {
|
fn validate_vm_type(vm_type: &str) -> crate::Result<()> {
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ impl ConsoleProxyManager {
|
|||||||
let ws_url = format!("{}{}", origin, path);
|
let ws_url = format!("{}{}", origin, path);
|
||||||
|
|
||||||
// Connect to Proxmox first so an unreachable server fails the command
|
// Connect to Proxmox first so an unreachable server fails the command
|
||||||
// instead of leaving a dangling local listener.
|
// instead of leaving a dangling local listener. The console websocket
|
||||||
let server = timeout(Duration::from_secs(20), connect_ws(&ws_url))
|
// authenticates via the ticket in the URL query, so no auth header is
|
||||||
|
// sent.
|
||||||
|
let server = timeout(Duration::from_secs(20), connect_ws(&ws_url, None))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))?
|
.map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))?
|
||||||
.map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?;
|
.map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?;
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Tauri error: {0}")]
|
#[error("Tauri error: {0}")]
|
||||||
TauriError(#[from] tauri::Error),
|
TauriError(#[from] tauri::Error),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for Error {
|
impl Serialize for Error {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! OS credential-store access for connection secrets, hardened against the
|
||||||
|
//! macOS Keychain's find-then-add behavior.
|
||||||
|
//!
|
||||||
|
//! Secrets live in the platform secure store: the macOS Keychain, the Windows
|
||||||
|
//! Credential Manager, or the Linux kernel keyutils (which needs no daemon and
|
||||||
|
//! therefore works headless). The store is selected once, on first use, via
|
||||||
|
//! [`keyring_core::set_default_store`].
|
||||||
|
//!
|
||||||
|
//! # macOS duplicate-item recovery
|
||||||
|
//!
|
||||||
|
//! The Keychain backend behind `keyring`/`security-framework` implements
|
||||||
|
//! "set" as *find then add*: the scoped lookup against the login keychain is
|
||||||
|
//! attempted first, and when it fails (for any reason) a new item is added.
|
||||||
|
//! If the login keychain is locked, the lookup fails, macOS prompts to unlock
|
||||||
|
//! it, and the subsequent add discovers the item created by an earlier login
|
||||||
|
//! still exists, returning `errSecDuplicateItem`. [`set_password`] detects
|
||||||
|
//! that OSStatus and recovers by deleting the stale item and retrying once,
|
||||||
|
//! so re-logins and ticket refreshes succeed after the keychain is unlocked.
|
||||||
|
//! Failures that persist are mapped to actionable messages via
|
||||||
|
//! [`describe_error`].
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub use keyring_core as keyring;
|
||||||
|
|
||||||
|
/// The Keychain/Credential Manager service name under which all Clustri
|
||||||
|
/// secrets are stored. Each entry's account is `"{connection_id}:{field}"`.
|
||||||
|
pub const SERVICE: &str = "clustri";
|
||||||
|
|
||||||
|
/// Initializes the platform credential store, exactly once, before the first
|
||||||
|
/// entry is created. Subsequent calls return the cached outcome.
|
||||||
|
fn init_store() -> keyring::Result<()> {
|
||||||
|
static INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
||||||
|
let cached = INIT.get_or_init(|| {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let store = apple_native_keyring_store::keychain::Store::new();
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let store = windows_native_keyring_store::Store::new();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let store = linux_keyutils_keyring_store::Store::new();
|
||||||
|
#[cfg(any(
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "linux"
|
||||||
|
))]
|
||||||
|
match store {
|
||||||
|
Ok(s) => {
|
||||||
|
keyring_core::set_default_store(s);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
#[cfg(not(any(
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "windows",
|
||||||
|
target_os = "linux"
|
||||||
|
)))]
|
||||||
|
Err("no keyring store is configured for this platform".to_string())
|
||||||
|
});
|
||||||
|
match cached {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(message) => Err(keyring::Error::Invalid(
|
||||||
|
"store".to_string(),
|
||||||
|
message.clone(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the keyring entry for a connection's field, initializing the
|
||||||
|
/// platform store on first use. Entry construction failures are mapped to
|
||||||
|
/// [`crate::Error::KeyringError`] so callers that tolerate a missing store
|
||||||
|
/// (e.g. headless Linux) can treat them as `None`.
|
||||||
|
pub fn entry(connection_id: &str, field: &str) -> crate::Result<keyring::Entry> {
|
||||||
|
init_store().map_err(|e| crate::Error::KeyringError(describe_error(&e)))?;
|
||||||
|
let key = format!("{}:{}", connection_id, field);
|
||||||
|
keyring::Entry::new(SERVICE, &key).map_err(|e| crate::Error::KeyringError(describe_error(&e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a secret for a connection field, recovering from the macOS
|
||||||
|
/// "item already exists" failure by deleting the stale item and retrying once.
|
||||||
|
pub fn set_password(connection_id: &str, field: &str, value: &str) -> crate::Result<()> {
|
||||||
|
let entry = entry(connection_id, field)?;
|
||||||
|
match entry.set_password(value) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::PlatformFailure(inner)) if is_duplicate_item(inner.as_ref()) => {
|
||||||
|
// The item exists but the pre-add lookup could not see it (the
|
||||||
|
// login keychain was locked during the lookup). Once the user has
|
||||||
|
// unlocked the keychain, deleting and re-adding succeeds.
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
entry
|
||||||
|
.set_password(value)
|
||||||
|
.map_err(|e| crate::Error::KeyringError(describe_error(&e)))
|
||||||
|
}
|
||||||
|
Err(e) => Err(crate::Error::KeyringError(describe_error(&e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a stored secret for a connection field. A missing entry is not an
|
||||||
|
/// error, so clearing credentials is idempotent.
|
||||||
|
pub fn delete_credential(connection_id: &str, field: &str) -> crate::Result<()> {
|
||||||
|
let entry = entry(connection_id, field)?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(crate::Error::KeyringError(describe_error(&e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a keyring error to a message with actionable guidance. On macOS the
|
||||||
|
/// underlying OSStatus is inspected for the known failure modes (duplicate
|
||||||
|
/// item, locked keychain, missing entitlement); everything else keeps the
|
||||||
|
/// platform detail so it can be reported or debugged.
|
||||||
|
pub fn describe_error(err: &keyring::Error) -> String {
|
||||||
|
match err {
|
||||||
|
keyring::Error::PlatformFailure(inner) => {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(sec_err) = inner.downcast_ref::<security_framework::base::Error>() {
|
||||||
|
return match sec_err.code() {
|
||||||
|
// errSecDuplicateItem: the item exists but could not be
|
||||||
|
// located for update (locked login keychain during the
|
||||||
|
// lookup, or a stray item in another keychain).
|
||||||
|
-25299 => format!(
|
||||||
|
"macOS Keychain: the stored item already exists and could not \
|
||||||
|
be replaced. Your login keychain is likely locked or its \
|
||||||
|
password is out of date. Open Keychain Access, unlock the \
|
||||||
|
'login' keychain (or use Edit > Change Password for Keychain \
|
||||||
|
'login'), then try again. If a duplicate '{SERVICE}' item is \
|
||||||
|
listed under the iCloud keychain, delete it there as well. \
|
||||||
|
({inner})"
|
||||||
|
),
|
||||||
|
// errSecAuthFailed: the keychain did not unlock.
|
||||||
|
-25293 => format!(
|
||||||
|
"macOS Keychain: the login keychain is locked or the password \
|
||||||
|
is incorrect. Unlock it in Keychain Access and try again. \
|
||||||
|
({inner})"
|
||||||
|
),
|
||||||
|
// errSecMissingEntitlement: unsigned app access denial.
|
||||||
|
-34018 => format!(
|
||||||
|
"macOS Keychain: access was denied because the app is not \
|
||||||
|
signed with keychain entitlements. ({inner})"
|
||||||
|
),
|
||||||
|
_ => format!("macOS Keychain error: {inner}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("Secure storage error: {inner}")
|
||||||
|
}
|
||||||
|
keyring::Error::NoStorageAccess(inner) => format!(
|
||||||
|
"Secure storage is locked or unavailable: {inner}. Unlock your login keychain \
|
||||||
|
(or keyring) and try again."
|
||||||
|
),
|
||||||
|
_ => err.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a platform error is the macOS `errSecDuplicateItem` code, which
|
||||||
|
/// the login keychain reports when an add hits an item that already exists.
|
||||||
|
fn is_duplicate_item(err: &(dyn std::error::Error + Send + Sync)) -> bool {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(sec_err) = err.downcast_ref::<security_framework::base::Error>() {
|
||||||
|
return sec_err.code() == -25299;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = err;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_error_explains_locked_storage() {
|
||||||
|
let err = keyring::Error::NoStorageAccess(Box::new(std::io::Error::other("locked")));
|
||||||
|
let msg = describe_error(&err);
|
||||||
|
assert!(msg.contains("locked or unavailable"), "{msg}");
|
||||||
|
assert!(msg.contains("Unlock"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_error_keeps_platform_detail() {
|
||||||
|
let err = keyring::Error::PlatformFailure(Box::new(std::io::Error::other("boom")));
|
||||||
|
let msg = describe_error(&err);
|
||||||
|
assert!(msg.contains("Secure storage error"), "{msg}");
|
||||||
|
assert!(msg.contains("boom"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn describe_error_passes_other_variants_through() {
|
||||||
|
let err = keyring::Error::Invalid("service".to_string(), "cannot be empty".to_string());
|
||||||
|
assert_eq!(describe_error(&err), err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_get_delete_round_trip() {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let field = "unit";
|
||||||
|
let value = "round-trip-secret";
|
||||||
|
set_password(&id, field, value).expect("set_password should succeed");
|
||||||
|
let entry = entry(&id, field).expect("entry should be constructible");
|
||||||
|
assert_eq!(
|
||||||
|
entry.get_password().expect("get_password should succeed"),
|
||||||
|
value
|
||||||
|
);
|
||||||
|
delete_credential(&id, field).expect("delete_credential should succeed");
|
||||||
|
match entry.get_password() {
|
||||||
|
Err(keyring::Error::NoEntry) => {}
|
||||||
|
other => panic!("expected NoEntry after delete, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_credential_is_idempotent() {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
delete_credential(&id, "unit").expect("deleting a missing entry should be a no-op");
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
-4
@@ -9,15 +9,20 @@ use tokio::sync::RwLock;
|
|||||||
mod connection;
|
mod connection;
|
||||||
mod console_proxy;
|
mod console_proxy;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod keyring;
|
||||||
|
mod pbs;
|
||||||
mod proxmox;
|
mod proxmox;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
mod websocket;
|
mod websocket;
|
||||||
|
|
||||||
pub use connection::{
|
pub use connection::{
|
||||||
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
|
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult, ServerType,
|
||||||
};
|
};
|
||||||
pub use console_proxy::ConsoleProxyInfo;
|
pub use console_proxy::ConsoleProxyInfo;
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
pub use pbs::{
|
||||||
|
PbsBackupGroup, PbsDatastore, PbsJob, PbsNodeStatus, PbsSnapshot, PbsSnapshotFile, PbsVersion,
|
||||||
|
};
|
||||||
pub use proxmox::{
|
pub use proxmox::{
|
||||||
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
|
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
|
||||||
RestoreConfig, UpdateVMConfig,
|
RestoreConfig, UpdateVMConfig,
|
||||||
@@ -48,6 +53,23 @@ pub struct ConnectionConfig {
|
|||||||
pub nodes: Vec<DiscoveredNode>,
|
pub nodes: Vec<DiscoveredNode>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cluster_id: Option<String>,
|
pub cluster_id: Option<String>,
|
||||||
|
#[serde(default = "default_server_type")]
|
||||||
|
pub server_type: String, // "pve" | "pbs"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default server type for connections added without an explicit value. PVE is
|
||||||
|
/// the historical behavior and remains the default so older persisted configs
|
||||||
|
/// (which have no `serverType` field) keep working.
|
||||||
|
fn default_server_type() -> String {
|
||||||
|
"pve".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionConfig {
|
||||||
|
/// True when this connection targets a Proxmox Backup Server (PBS) instead
|
||||||
|
/// of a Proxmox VE cluster.
|
||||||
|
pub fn is_pbs(&self) -> bool {
|
||||||
|
self.server_type == "pbs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A node discovered in the cluster connected through a
|
/// A node discovered in the cluster connected through a
|
||||||
@@ -299,7 +321,14 @@ async fn get_tasks(
|
|||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<Vec<proxmox::Task>> {
|
) -> Result<Vec<proxmox::Task>> {
|
||||||
let manager = state.connection_manager.read().await;
|
let manager = state.connection_manager.read().await;
|
||||||
manager.get_tasks(&connection_id).await
|
// PBS exposes its task list under `/nodes/localhost/tasks` with a
|
||||||
|
// different payload shape than the PVE `/cluster/tasks` endpoint, so the
|
||||||
|
// server type selects the backing method.
|
||||||
|
if manager.is_pbs(&connection_id)? {
|
||||||
|
manager.pbs_get_tasks(&connection_id).await
|
||||||
|
} else {
|
||||||
|
manager.get_tasks(&connection_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -638,9 +667,10 @@ async fn login_with_token(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
url: String,
|
url: String,
|
||||||
token: String,
|
token: String,
|
||||||
|
server_type: String,
|
||||||
) -> Result<LoginResult> {
|
) -> Result<LoginResult> {
|
||||||
let manager = state.connection_manager.read().await;
|
let manager = state.connection_manager.read().await;
|
||||||
manager.login_with_token(&url, &token).await
|
manager.login_with_token(&url, &token, &server_type).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -716,8 +746,18 @@ async fn connect_websocket(
|
|||||||
url: String,
|
url: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
// Resolve the auth header server-side from the connection's stored
|
||||||
|
// credentials (token from config/keyring, ticket from session/keyring) so
|
||||||
|
// the frontend never has to hold secrets. A connection with no resolvable
|
||||||
|
// secret connects without an auth header.
|
||||||
|
let auth_header = {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.auth_header_for(&connection_id).ok().flatten()
|
||||||
|
};
|
||||||
let mut ws_manager = state.ws_manager.write().await;
|
let mut ws_manager = state.ws_manager.write().await;
|
||||||
ws_manager.connect(connection_id, url, app_handle).await
|
ws_manager
|
||||||
|
.connect(connection_id, url, auth_header, app_handle)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -847,6 +887,222 @@ async fn delete_backup(
|
|||||||
manager.delete_backup(&connection_id, &volid).await
|
manager.delete_backup(&connection_id, &volid).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proxmox Backup Server (PBS) commands
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_datastores(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<Vec<pbs::PbsDatastore>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_get_datastores(&connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_version(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<pbs::PbsVersion> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_get_version(&connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_node_status(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> Result<pbs::PbsNodeStatus> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_get_node_status(&connection_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_groups(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
) -> Result<Vec<pbs::PbsBackupGroup>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_get_groups(&connection_id, &store).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_snapshots(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
backup_id: String,
|
||||||
|
backup_type: String,
|
||||||
|
) -> Result<Vec<pbs::PbsSnapshot>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_get_snapshots(&connection_id, &store, &backup_id, &backup_type)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_snapshot_files(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
backup_id: String,
|
||||||
|
backup_type: String,
|
||||||
|
backup_time: i64,
|
||||||
|
) -> Result<Vec<pbs::PbsSnapshotFile>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_get_snapshot_files(&connection_id, &store, &backup_id, &backup_type, backup_time)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
//
|
||||||
|
// The parameter list is the Tauri invoke IPC contract with the frontend
|
||||||
|
// (`downloadPbsSnapshotFile` in src/lib/tauri.ts), so the args cannot be
|
||||||
|
// grouped without changing the frontend call site.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn download_pbs_snapshot_file(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
backup_id: String,
|
||||||
|
backup_type: String,
|
||||||
|
backup_time: i64,
|
||||||
|
file_name: String,
|
||||||
|
decoded: bool,
|
||||||
|
save_path: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_download_snapshot_file(
|
||||||
|
&connection_id,
|
||||||
|
&store,
|
||||||
|
&backup_id,
|
||||||
|
&backup_type,
|
||||||
|
backup_time,
|
||||||
|
&file_name,
|
||||||
|
decoded,
|
||||||
|
&save_path,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_pbs_snapshot(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
backup_id: String,
|
||||||
|
backup_type: String,
|
||||||
|
backup_time: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_delete_snapshot(&connection_id, &store, &backup_id, &backup_type, backup_time)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn delete_pbs_group(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
backup_id: String,
|
||||||
|
backup_type: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_delete_group(&connection_id, &store, &backup_id, &backup_type)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn run_pbs_verify(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_run_verify(&connection_id, &store).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
//
|
||||||
|
// The parameter list is the Tauri invoke IPC contract with the frontend
|
||||||
|
// (`runPbsPrune` in src/lib/tauri.ts), so the args cannot be grouped without
|
||||||
|
// changing the frontend call site.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn run_pbs_prune(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
keep_last: Option<u32>,
|
||||||
|
keep_daily: Option<u32>,
|
||||||
|
keep_weekly: Option<u32>,
|
||||||
|
keep_monthly: Option<u32>,
|
||||||
|
keep_yearly: Option<u32>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<String> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_run_prune(
|
||||||
|
&connection_id,
|
||||||
|
&store,
|
||||||
|
keep_last,
|
||||||
|
keep_daily,
|
||||||
|
keep_weekly,
|
||||||
|
keep_monthly,
|
||||||
|
keep_yearly,
|
||||||
|
dry_run,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn run_pbs_gc(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager.pbs_run_gc(&connection_id, &store).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_verify_jobs(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: Option<String>,
|
||||||
|
) -> Result<Vec<pbs::PbsJob>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_get_verify_jobs(&connection_id, store.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_prune_jobs(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: Option<String>,
|
||||||
|
) -> Result<Vec<pbs::PbsJob>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_get_prune_jobs(&connection_id, store.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_pbs_gc_jobs(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
store: Option<String>,
|
||||||
|
) -> Result<Vec<pbs::PbsJob>> {
|
||||||
|
let manager = state.connection_manager.read().await;
|
||||||
|
manager
|
||||||
|
.pbs_get_gc_jobs(&connection_id, store.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TrayConnectionInfo {
|
pub struct TrayConnectionInfo {
|
||||||
@@ -975,8 +1231,24 @@ pub fn run() {
|
|||||||
run_backup,
|
run_backup,
|
||||||
restore_backup,
|
restore_backup,
|
||||||
delete_backup,
|
delete_backup,
|
||||||
|
get_pbs_datastores,
|
||||||
|
get_pbs_version,
|
||||||
|
get_pbs_node_status,
|
||||||
|
get_pbs_groups,
|
||||||
|
get_pbs_snapshots,
|
||||||
|
get_pbs_snapshot_files,
|
||||||
|
download_pbs_snapshot_file,
|
||||||
|
delete_pbs_snapshot,
|
||||||
|
delete_pbs_group,
|
||||||
|
run_pbs_verify,
|
||||||
|
run_pbs_prune,
|
||||||
|
run_pbs_gc,
|
||||||
|
get_pbs_verify_jobs,
|
||||||
|
get_pbs_prune_jobs,
|
||||||
|
get_pbs_gc_jobs,
|
||||||
update_tray_menu,
|
update_tray_menu,
|
||||||
])
|
])
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Build the system tray menu
|
// Build the system tray menu
|
||||||
let show_hide = MenuItemBuilder::new("Show / Hide")
|
let show_hide = MenuItemBuilder::new("Show / Hide")
|
||||||
|
|||||||
@@ -0,0 +1,756 @@
|
|||||||
|
//! Proxmox Backup Server (PBS) backend.
|
||||||
|
//!
|
||||||
|
//! PBS shares the JSON `{data}` envelope and the token/ticket authentication
|
||||||
|
//! model with Proxmox VE, but it is single-host and exposes its own endpoint
|
||||||
|
//! set. All datastore operations live here; the HTTP plumbing (auth headers,
|
||||||
|
//! endpoint rotation, envelope unwrapping, binary downloads) is shared from
|
||||||
|
//! `crate::connection`.
|
||||||
|
//!
|
||||||
|
//! The PBS API reports kebab-case JSON keys. The public structs serialize as
|
||||||
|
//! camelCase for the frontend, so the ones deserialized straight from a
|
||||||
|
//! response use `rename_all(serialize = "camelCase", deserialize =
|
||||||
|
//! "kebab-case")`; the datastore and gc-status structs are assembled from raw
|
||||||
|
//! kebab-case intermediate structs and only ever serialize to the frontend.
|
||||||
|
|
||||||
|
use crate::connection::{parse_api, ConnectionManager};
|
||||||
|
use crate::proxmox::Task;
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public types (frontend-facing camelCase shapes)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsDatastore {
|
||||||
|
pub store: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mount_status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub maintenance: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub used: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub avail: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub estimated_full_date: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub history: Option<Vec<f64>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gc_status: Option<PbsGcStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsGcStatus {
|
||||||
|
#[serde(default)]
|
||||||
|
pub disk_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disk_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub index_data_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub index_file_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub removed_bad: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub removed_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub removed_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub still_bad: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_hits: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_misses: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsVersion {
|
||||||
|
pub version: String,
|
||||||
|
pub release: String,
|
||||||
|
pub repoid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||||
|
pub struct PbsNodeStatus {
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpu: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub loadavg: Option<Vec<f64>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub uptime: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory: Option<PbsMem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub root: Option<PbsMem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub swap: Option<PbsMem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpuinfo: Option<PbsCpuInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_kernel: Option<PbsKernel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsMem {
|
||||||
|
#[serde(default)]
|
||||||
|
pub free: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub used: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsCpuInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpus: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sockets: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsKernel {
|
||||||
|
#[serde(default)]
|
||||||
|
pub machine: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub release: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sysname: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||||
|
pub struct PbsBackupGroup {
|
||||||
|
pub backup_id: String,
|
||||||
|
pub backup_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backup_count: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_backup: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||||
|
pub struct PbsSnapshot {
|
||||||
|
pub backup_id: String,
|
||||||
|
pub backup_type: String,
|
||||||
|
pub backup_time: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub protected: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub files: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verification: Option<PbsVerification>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PbsVerification {
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||||
|
pub struct PbsSnapshotFile {
|
||||||
|
pub filename: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub crypt_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
|
||||||
|
pub struct PbsJob {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub store: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub schedule: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_run_state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_run_endtime: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_run: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_last: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_daily: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_weekly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_monthly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub keep_yearly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ignore_verified: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_depth: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raw kebab-case response shapes (not serialized to the frontend)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Raw `/status/datastore-usage` entry. The usage endpoint reports kebab-case
|
||||||
|
/// keys and nests the cache stats inside `gc-status.cache-stats`, so the
|
||||||
|
/// public [`PbsDatastore`]/[`PbsGcStatus`] structs are built from this by
|
||||||
|
/// hand rather than deserialized directly.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct PbsDatastoreUsageRaw {
|
||||||
|
store: String,
|
||||||
|
#[serde(default)]
|
||||||
|
backend_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
mount_status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
avail: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
total: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
used: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
estimated_full_date: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
history: Option<Vec<f64>>,
|
||||||
|
#[serde(default)]
|
||||||
|
gc_status: Option<PbsGcStatusRaw>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct PbsGcStatusRaw {
|
||||||
|
#[serde(default)]
|
||||||
|
disk_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
disk_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
index_data_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
index_file_count: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pending_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pending_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
removed_bad: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
removed_bytes: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
removed_chunks: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
still_bad: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_stats: Option<PbsCacheStatsRaw>,
|
||||||
|
#[serde(default)]
|
||||||
|
upid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PbsCacheStatsRaw {
|
||||||
|
#[serde(default)]
|
||||||
|
hits: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
misses: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `/admin/datastore` entry carrying the static datastore config that the
|
||||||
|
/// usage endpoint does not report (comment, maintenance).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct PbsDatastoreConfigRaw {
|
||||||
|
store: String,
|
||||||
|
#[serde(default)]
|
||||||
|
comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
backend_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
mount_status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
maintenance: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw `/admin/gc` job entry. GC jobs carry no `id` — the store identifies the
|
||||||
|
/// job — so `id` falls back to `store` when mapping to [`PbsJob`].
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
struct PbsGcJobRaw {
|
||||||
|
store: String,
|
||||||
|
#[serde(default)]
|
||||||
|
schedule: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
disable: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_run_state: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_run_endtime: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
next_run: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
keep_last: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
keep_daily: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
keep_weekly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
keep_monthly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
keep_yearly: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
ignore_verified: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_depth: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PbsDatastoreUsageRaw> for PbsDatastore {
|
||||||
|
fn from(raw: PbsDatastoreUsageRaw) -> Self {
|
||||||
|
PbsDatastore {
|
||||||
|
store: raw.store,
|
||||||
|
comment: None,
|
||||||
|
backend_type: raw.backend_type,
|
||||||
|
mount_status: raw.mount_status,
|
||||||
|
maintenance: None,
|
||||||
|
total: raw.total,
|
||||||
|
used: raw.used,
|
||||||
|
avail: raw.avail,
|
||||||
|
error: raw.error,
|
||||||
|
estimated_full_date: raw.estimated_full_date,
|
||||||
|
history: raw.history,
|
||||||
|
gc_status: raw.gc_status.map(Into::into),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PbsGcStatusRaw> for PbsGcStatus {
|
||||||
|
fn from(raw: PbsGcStatusRaw) -> Self {
|
||||||
|
PbsGcStatus {
|
||||||
|
disk_bytes: raw.disk_bytes,
|
||||||
|
disk_chunks: raw.disk_chunks,
|
||||||
|
index_data_bytes: raw.index_data_bytes,
|
||||||
|
index_file_count: raw.index_file_count,
|
||||||
|
pending_bytes: raw.pending_bytes,
|
||||||
|
pending_chunks: raw.pending_chunks,
|
||||||
|
removed_bad: raw.removed_bad,
|
||||||
|
removed_bytes: raw.removed_bytes,
|
||||||
|
removed_chunks: raw.removed_chunks,
|
||||||
|
still_bad: raw.still_bad,
|
||||||
|
cache_hits: raw.cache_stats.as_ref().and_then(|stats| stats.hits),
|
||||||
|
cache_misses: raw.cache_stats.as_ref().and_then(|stats| stats.misses),
|
||||||
|
upid: raw.upid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PbsGcJobRaw> for PbsJob {
|
||||||
|
fn from(raw: PbsGcJobRaw) -> Self {
|
||||||
|
PbsJob {
|
||||||
|
id: raw.store.clone(),
|
||||||
|
store: Some(raw.store),
|
||||||
|
schedule: raw.schedule,
|
||||||
|
comment: raw.comment,
|
||||||
|
disable: raw.disable,
|
||||||
|
last_run_state: raw.last_run_state,
|
||||||
|
last_run_endtime: raw.last_run_endtime,
|
||||||
|
next_run: raw.next_run,
|
||||||
|
keep_last: raw.keep_last,
|
||||||
|
keep_daily: raw.keep_daily,
|
||||||
|
keep_weekly: raw.keep_weekly,
|
||||||
|
keep_monthly: raw.keep_monthly,
|
||||||
|
keep_yearly: raw.keep_yearly,
|
||||||
|
ignore_verified: raw.ignore_verified,
|
||||||
|
max_depth: raw.max_depth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PBS API methods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl ConnectionManager {
|
||||||
|
/// True when the connection targets a PBS server rather than a PVE
|
||||||
|
/// cluster.
|
||||||
|
pub fn is_pbs(&self, connection_id: &str) -> crate::Result<bool> {
|
||||||
|
Ok(self.connection(connection_id)?.config.server_type == "pbs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the datastores with their live usage from `/status/datastore-usage`,
|
||||||
|
/// then merges the static datastore config (`/admin/datastore`: comment,
|
||||||
|
/// maintenance, mount status) onto each entry by `store`. A failure of the
|
||||||
|
/// usage call propagates; a failure of the config call degrades to the
|
||||||
|
/// usage-only list.
|
||||||
|
pub async fn pbs_get_datastores(&self, connection_id: &str) -> crate::Result<Vec<PbsDatastore>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let data = conn
|
||||||
|
.request(Method::GET, "/status/datastore-usage", &[], None)
|
||||||
|
.await?;
|
||||||
|
let usage: Vec<PbsDatastoreUsageRaw> = parse_api("/status/datastore-usage", data)?;
|
||||||
|
let mut datastores: Vec<PbsDatastore> = usage.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
|
if let Ok(config_data) = conn.request(Method::GET, "/admin/datastore", &[], None).await {
|
||||||
|
if let Ok(configs) =
|
||||||
|
parse_api::<Vec<PbsDatastoreConfigRaw>>("/admin/datastore", config_data)
|
||||||
|
{
|
||||||
|
for config in configs {
|
||||||
|
if let Some(datastore) = datastores
|
||||||
|
.iter_mut()
|
||||||
|
.find(|datastore| datastore.store == config.store)
|
||||||
|
{
|
||||||
|
// Only overwrite with fields the config actually
|
||||||
|
// reports, so usage-derived values survive an omitted
|
||||||
|
// key.
|
||||||
|
if config.comment.is_some() {
|
||||||
|
datastore.comment = config.comment;
|
||||||
|
}
|
||||||
|
if config.backend_type.is_some() {
|
||||||
|
datastore.backend_type = config.backend_type;
|
||||||
|
}
|
||||||
|
if config.mount_status.is_some() {
|
||||||
|
datastore.mount_status = config.mount_status;
|
||||||
|
}
|
||||||
|
if config.maintenance.is_some() {
|
||||||
|
datastore.maintenance = config.maintenance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(datastores)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the server version information.
|
||||||
|
pub async fn pbs_get_version(&self, connection_id: &str) -> crate::Result<PbsVersion> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let data = conn.request(Method::GET, "/version", &[], None).await?;
|
||||||
|
parse_api("/version", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the resource usage of the local node. PBS is single-host, so the
|
||||||
|
/// `localhost` node is always the one being managed.
|
||||||
|
pub async fn pbs_get_node_status(&self, connection_id: &str) -> crate::Result<PbsNodeStatus> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let data = conn
|
||||||
|
.request(Method::GET, "/nodes/localhost/status", &[], None)
|
||||||
|
.await?;
|
||||||
|
parse_api("/nodes/localhost/status", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the backup groups (per `backup-id`/`backup-type`) of a datastore.
|
||||||
|
pub async fn pbs_get_groups(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
) -> crate::Result<Vec<PbsBackupGroup>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/groups", store);
|
||||||
|
let data = conn.request(Method::GET, &path, &[], None).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the snapshots of one backup group.
|
||||||
|
pub async fn pbs_get_snapshots(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
backup_id: &str,
|
||||||
|
backup_type: &str,
|
||||||
|
) -> crate::Result<Vec<PbsSnapshot>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/snapshots", store);
|
||||||
|
let query = [
|
||||||
|
("backup-id", backup_id.to_string()),
|
||||||
|
("backup-type", backup_type.to_string()),
|
||||||
|
];
|
||||||
|
let data = conn.request(Method::GET, &path, &query, None).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the files of one snapshot.
|
||||||
|
pub async fn pbs_get_snapshot_files(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
backup_id: &str,
|
||||||
|
backup_type: &str,
|
||||||
|
backup_time: i64,
|
||||||
|
) -> crate::Result<Vec<PbsSnapshotFile>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/files", store);
|
||||||
|
let query = [
|
||||||
|
("backup-id", backup_id.to_string()),
|
||||||
|
("backup-type", backup_type.to_string()),
|
||||||
|
("backup-time", backup_time.to_string()),
|
||||||
|
];
|
||||||
|
let data = conn.request(Method::GET, &path, &query, None).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams a snapshot file to `save_path` and returns the path. `decoded`
|
||||||
|
/// selects the `download-decoded` endpoint (raw plaintext archive bytes,
|
||||||
|
/// only available for unencrypted datastores) over the plain `download`
|
||||||
|
/// endpoint (raw archive bytes, possibly encrypted).
|
||||||
|
//
|
||||||
|
// The argument list mirrors the `download_pbs_snapshot_file` Tauri command
|
||||||
|
// (the invoke IPC contract), so it cannot be grouped without breaking the
|
||||||
|
// frontend call sites.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn pbs_download_snapshot_file(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
backup_id: &str,
|
||||||
|
backup_type: &str,
|
||||||
|
backup_time: i64,
|
||||||
|
file_name: &str,
|
||||||
|
decoded: bool,
|
||||||
|
save_path: &str,
|
||||||
|
) -> crate::Result<String> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let endpoint = if decoded { "download-decoded" } else { "download" };
|
||||||
|
let path = format!("/admin/datastore/{}/{}", store, endpoint);
|
||||||
|
let query = [
|
||||||
|
("backup-id", backup_id.to_string()),
|
||||||
|
("backup-type", backup_type.to_string()),
|
||||||
|
("backup-time", backup_time.to_string()),
|
||||||
|
("file-name", file_name.to_string()),
|
||||||
|
];
|
||||||
|
conn.download_to_file(&path, &query, Path::new(save_path))
|
||||||
|
.await?;
|
||||||
|
Ok(save_path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a single snapshot.
|
||||||
|
pub async fn pbs_delete_snapshot(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
backup_id: &str,
|
||||||
|
backup_type: &str,
|
||||||
|
backup_time: i64,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/snapshots", store);
|
||||||
|
let query = [
|
||||||
|
("backup-id", backup_id.to_string()),
|
||||||
|
("backup-type", backup_type.to_string()),
|
||||||
|
("backup-time", backup_time.to_string()),
|
||||||
|
];
|
||||||
|
conn.request(Method::DELETE, &path, &query, None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a whole backup group (all of its snapshots).
|
||||||
|
pub async fn pbs_delete_group(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
backup_id: &str,
|
||||||
|
backup_type: &str,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/groups", store);
|
||||||
|
let query = [
|
||||||
|
("backup-id", backup_id.to_string()),
|
||||||
|
("backup-type", backup_type.to_string()),
|
||||||
|
];
|
||||||
|
conn.request(Method::DELETE, &path, &query, None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a verification task for a datastore and returns the UPID.
|
||||||
|
pub async fn pbs_run_verify(&self, connection_id: &str, store: &str) -> crate::Result<String> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/verify", store);
|
||||||
|
let form = [("store", store.to_string())];
|
||||||
|
let data = conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a prune task for a datastore and returns the UPID. Only the
|
||||||
|
/// provided keep-* retention fields are sent; `dry_run` marks the run as
|
||||||
|
/// a simulation.
|
||||||
|
//
|
||||||
|
// The argument list mirrors the `run_pbs_prune` Tauri command (the invoke
|
||||||
|
// IPC contract), so it cannot be grouped without breaking the frontend
|
||||||
|
// call sites.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn pbs_run_prune(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: &str,
|
||||||
|
keep_last: Option<u32>,
|
||||||
|
keep_daily: Option<u32>,
|
||||||
|
keep_weekly: Option<u32>,
|
||||||
|
keep_monthly: Option<u32>,
|
||||||
|
keep_yearly: Option<u32>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> crate::Result<String> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/prune-datastore", store);
|
||||||
|
let mut form: Vec<(&str, String)> = vec![("store", store.to_string())];
|
||||||
|
if let Some(keep) = keep_last {
|
||||||
|
form.push(("keep-last", keep.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(keep) = keep_daily {
|
||||||
|
form.push(("keep-daily", keep.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(keep) = keep_weekly {
|
||||||
|
form.push(("keep-weekly", keep.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(keep) = keep_monthly {
|
||||||
|
form.push(("keep-monthly", keep.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(keep) = keep_yearly {
|
||||||
|
form.push(("keep-yearly", keep.to_string()));
|
||||||
|
}
|
||||||
|
if dry_run {
|
||||||
|
form.push(("dry-run", "1".to_string()));
|
||||||
|
}
|
||||||
|
let data = conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a garbage-collection task for a datastore and returns the UPID.
|
||||||
|
pub async fn pbs_run_gc(&self, connection_id: &str, store: &str) -> crate::Result<String> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let path = format!("/admin/datastore/{}/gc", store);
|
||||||
|
let form = [("store", store.to_string())];
|
||||||
|
let data = conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||||
|
parse_api(&path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the verification jobs, optionally filtered by datastore.
|
||||||
|
pub async fn pbs_get_verify_jobs(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: Option<&str>,
|
||||||
|
) -> crate::Result<Vec<PbsJob>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let query = store
|
||||||
|
.map(|store| vec![("store", store.to_string())])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let data = conn.request(Method::GET, "/admin/verify", &query, None).await?;
|
||||||
|
parse_api("/admin/verify", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the prune jobs, optionally filtered by datastore.
|
||||||
|
pub async fn pbs_get_prune_jobs(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: Option<&str>,
|
||||||
|
) -> crate::Result<Vec<PbsJob>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let query = store
|
||||||
|
.map(|store| vec![("store", store.to_string())])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let data = conn.request(Method::GET, "/admin/prune", &query, None).await?;
|
||||||
|
parse_api("/admin/prune", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the garbage-collection jobs, optionally filtered by datastore.
|
||||||
|
/// GC jobs carry no `id` of their own, so the datastore name is used.
|
||||||
|
pub async fn pbs_get_gc_jobs(
|
||||||
|
&self,
|
||||||
|
connection_id: &str,
|
||||||
|
store: Option<&str>,
|
||||||
|
) -> crate::Result<Vec<PbsJob>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let query = store
|
||||||
|
.map(|store| vec![("store", store.to_string())])
|
||||||
|
.unwrap_or_default();
|
||||||
|
let data = conn.request(Method::GET, "/admin/gc", &query, None).await?;
|
||||||
|
let raw: Vec<PbsGcJobRaw> = parse_api("/admin/gc", data)?;
|
||||||
|
Ok(raw.into_iter().map(PbsJob::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the tasks running on the local PBS node, mapping the kebab-case
|
||||||
|
/// `worker-type`/`worker-id` keys and the PBS task status codes onto the
|
||||||
|
/// shared [`Task`] shape (the same struct the PVE task list uses).
|
||||||
|
pub async fn pbs_get_tasks(&self, connection_id: &str) -> crate::Result<Vec<Task>> {
|
||||||
|
let conn = self.connection(connection_id)?;
|
||||||
|
let data = conn
|
||||||
|
.request(Method::GET, "/nodes/localhost/tasks", &[], None)
|
||||||
|
.await?;
|
||||||
|
let entries: Vec<serde_json::Value> = parse_api("/nodes/localhost/tasks", data)?;
|
||||||
|
let mut tasks = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
// PBS reports `running` while a task is active and `ok` /
|
||||||
|
// `warning` / `error` once it finished. `ok` is mapped onto the
|
||||||
|
// PVE-style `exitstatus` so the shared task row renders the same
|
||||||
|
// way for both platforms.
|
||||||
|
let (status, exitstatus) = match entry["status"].as_str() {
|
||||||
|
Some("running") => (Some("running".to_string()), None),
|
||||||
|
Some("ok") => (None, Some("OK".to_string())),
|
||||||
|
Some("warning") => (None, Some("WARNING".to_string())),
|
||||||
|
Some("error") => (None, Some("ERROR".to_string())),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
tasks.push(Task {
|
||||||
|
upid: entry["upid"].as_str().unwrap_or("").to_string(),
|
||||||
|
node: entry["node"].as_str().unwrap_or("").to_string(),
|
||||||
|
pid: entry["pid"].as_u64().unwrap_or(0) as u32,
|
||||||
|
pstart: entry["pstart"].as_u64().unwrap_or(0),
|
||||||
|
starttime: entry["starttime"].as_u64().unwrap_or(0),
|
||||||
|
endtime: entry["endtime"].as_u64(),
|
||||||
|
r#type: entry["worker-type"].as_str().unwrap_or("").to_string(),
|
||||||
|
id: entry["worker-id"].as_str().unwrap_or("").to_string(),
|
||||||
|
user: entry["user"].as_str().unwrap_or("").to_string(),
|
||||||
|
status,
|
||||||
|
exitstatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,26 @@ use crate::error::Error;
|
|||||||
/// rejects the self-signed certificates typical of home-lab Proxmox servers.
|
/// rejects the self-signed certificates typical of home-lab Proxmox servers.
|
||||||
/// As with the HTTP transport, the application enforces trust at its own layer
|
/// As with the HTTP transport, the application enforces trust at its own layer
|
||||||
/// (TOFU pinning in `tls.rs`), so the transport here accepts any certificate.
|
/// (TOFU pinning in `tls.rs`), so the transport here accepts any certificate.
|
||||||
|
///
|
||||||
|
/// When `auth_header` is `Some((name, value))` the header is attached to the
|
||||||
|
/// handshake request (e.g. `("Cookie", "PVEAuthCookie=...")` or
|
||||||
|
/// `("Authorization", "PVEAPIToken=...")`). Unparseable header names/values
|
||||||
|
/// are silently ignored so a bad secret never breaks the connection outright.
|
||||||
pub async fn connect_ws(
|
pub async fn connect_ws(
|
||||||
url: &str,
|
url: &str,
|
||||||
|
auth_header: Option<(String, String)>,
|
||||||
) -> crate::Result<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>
|
) -> crate::Result<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>
|
||||||
{
|
{
|
||||||
let request = url
|
let mut request = url
|
||||||
.into_client_request()
|
.into_client_request()
|
||||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||||
|
if let Some((name, value)) = auth_header {
|
||||||
|
if let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) {
|
||||||
|
if let Ok(value) = http::HeaderValue::from_str(&value) {
|
||||||
|
request.headers_mut().insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let is_wss = request.uri().scheme_str() == Some("wss");
|
let is_wss = request.uri().scheme_str() == Some("wss");
|
||||||
|
|
||||||
let connector = if is_wss {
|
let connector = if is_wss {
|
||||||
@@ -90,11 +103,13 @@ impl WebSocketManager {
|
|||||||
/// Connect to a Proxmox WebSocket URL for the given connection ID.
|
/// Connect to a Proxmox WebSocket URL for the given connection ID.
|
||||||
///
|
///
|
||||||
/// Messages are forwarded as Tauri events via `app_handle`. If a connection
|
/// Messages are forwarded as Tauri events via `app_handle`. If a connection
|
||||||
/// already exists for this ID, it is disconnected first.
|
/// already exists for this ID, it is disconnected first. `auth_header`
|
||||||
|
/// (when present) is attached to every connection attempt's handshake.
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
&mut self,
|
&mut self,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
url: String,
|
url: String,
|
||||||
|
auth_header: Option<(String, String)>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
// Disconnect any existing connection for this ID
|
// Disconnect any existing connection for this ID
|
||||||
@@ -116,7 +131,7 @@ impl WebSocketManager {
|
|||||||
_ = shutdown_rx.recv() => {
|
_ = shutdown_rx.recv() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
result = connect_and_run(&cid, &ws_url, &app_handle) => {
|
result = connect_and_run(&cid, &ws_url, auth_header.clone(), &app_handle) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
reconnect_delay = Duration::from_secs(1);
|
reconnect_delay = Duration::from_secs(1);
|
||||||
@@ -170,9 +185,10 @@ impl WebSocketManager {
|
|||||||
async fn connect_and_run(
|
async fn connect_and_run(
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
auth_header: Option<(String, String)>,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
let ws_stream = connect_ws(url).await?;
|
let ws_stream = connect_ws(url, auth_header).await?;
|
||||||
|
|
||||||
let cid = connection_id.to_string();
|
let cid = connection_id.to_string();
|
||||||
let (mut write, mut read) = ws_stream.split();
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! error mapping, query parameters, and URL construction.
|
//! error mapping, query parameters, and URL construction.
|
||||||
|
|
||||||
use httpmock::prelude::*;
|
use httpmock::prelude::*;
|
||||||
use clustri::{api_request, AuthContext, AuthMode, Error};
|
use clustri::{api_request, AuthContext, AuthMode, Error, ServerType};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use reqwest::Method as RMethod;
|
use reqwest::Method as RMethod;
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ fn token_auth() -> AuthContext {
|
|||||||
token: Some("root@pam!test-token".to_string()),
|
token: Some("root@pam!test-token".to_string()),
|
||||||
ticket: None,
|
ticket: None,
|
||||||
csrf_token: None,
|
csrf_token: None,
|
||||||
|
server_type: ServerType::Pve,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ fn password_auth() -> AuthContext {
|
|||||||
token: None,
|
token: None,
|
||||||
ticket: Some(TICKET.to_string()),
|
ticket: Some(TICKET.to_string()),
|
||||||
csrf_token: Some(CSRF_TOKEN.to_string()),
|
csrf_token: Some(CSRF_TOKEN.to_string()),
|
||||||
|
server_type: ServerType::Pve,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ async fn setup_manager(
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async fn setup_manager(
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
//! Integration tests for the Proxmox Backup Server (PBS) backend.
|
||||||
|
//!
|
||||||
|
//! These tests exercise the `pbs_*` `ConnectionManager` methods against a
|
||||||
|
//! local HTTP mock server: the `PBSAPIToken`/`PBSAuthCookie` auth headers,
|
||||||
|
//! datastore usage+config merging, the datastore/group/snapshot read
|
||||||
|
//! endpoints, the snapshot/group deletions, the verify/prune/gc task
|
||||||
|
//! launches, the binary file download, the kebab-case `worker-*` task
|
||||||
|
//! mapping, and the PBS-specific connect/login flows (which skip cluster
|
||||||
|
//! discovery).
|
||||||
|
|
||||||
|
use httpmock::prelude::*;
|
||||||
|
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig};
|
||||||
|
|
||||||
|
const TOKEN: &str = "root@pam!pbs-token";
|
||||||
|
|
||||||
|
/// Builds a `ConnectionManager` with a single token-mode PBS connection whose
|
||||||
|
/// primary endpoint points at the mock server.
|
||||||
|
async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
let path = dir.path().join("connections.json");
|
||||||
|
let mut manager = ConnectionManager::new();
|
||||||
|
let config = ConnectionConfig {
|
||||||
|
id: "conn".to_string(),
|
||||||
|
name: "conn".to_string(),
|
||||||
|
primary: EndpointConfig {
|
||||||
|
url: url.to_string(),
|
||||||
|
node: None,
|
||||||
|
token: Some(token.to_string()),
|
||||||
|
},
|
||||||
|
fallbacks: vec![],
|
||||||
|
cert_fingerprint: None,
|
||||||
|
trusted: false,
|
||||||
|
accept_untrusted: true,
|
||||||
|
status: "disconnected".to_string(),
|
||||||
|
cluster_name: None,
|
||||||
|
is_cluster: false,
|
||||||
|
auth_mode: "token".to_string(),
|
||||||
|
username: None,
|
||||||
|
nodes: vec![],
|
||||||
|
cluster_id: None,
|
||||||
|
server_type: "pbs".to_string(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.add_connection(config, &path)
|
||||||
|
.await
|
||||||
|
.expect("connection should be added");
|
||||||
|
(manager, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_auth_sends_pbs_api_token_header() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/version")
|
||||||
|
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let version = manager
|
||||||
|
.pbs_get_version("conn")
|
||||||
|
.await
|
||||||
|
.expect("version should be fetched");
|
||||||
|
assert_eq!(version.version, "3.2.3");
|
||||||
|
assert_eq!(version.release, "bookworm");
|
||||||
|
assert_eq!(version.repoid, "dd6b00e2");
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn password_mode_sends_pbs_auth_cookie() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/status/datastore-usage")
|
||||||
|
.header("Cookie", "PBSAuthCookie=ticket-123");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":[]}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
let path = dir.path().join("connections.json");
|
||||||
|
let mut manager = ConnectionManager::new();
|
||||||
|
let config = ConnectionConfig {
|
||||||
|
id: "conn".to_string(),
|
||||||
|
name: "conn".to_string(),
|
||||||
|
primary: EndpointConfig {
|
||||||
|
url: server.base_url(),
|
||||||
|
node: None,
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
fallbacks: vec![],
|
||||||
|
cert_fingerprint: None,
|
||||||
|
trusted: false,
|
||||||
|
accept_untrusted: true,
|
||||||
|
status: "disconnected".to_string(),
|
||||||
|
cluster_name: None,
|
||||||
|
is_cluster: false,
|
||||||
|
auth_mode: "password".to_string(),
|
||||||
|
username: Some("root@pam".to_string()),
|
||||||
|
nodes: vec![],
|
||||||
|
cluster_id: None,
|
||||||
|
server_type: "pbs".to_string(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.add_connection(config, &path)
|
||||||
|
.await
|
||||||
|
.expect("connection should be added");
|
||||||
|
manager
|
||||||
|
.set_session_ticket("conn", "ticket-123", "csrf")
|
||||||
|
.await
|
||||||
|
.expect("session should be injected");
|
||||||
|
|
||||||
|
// The `/admin/datastore` merge call is best-effort; with no mock for it,
|
||||||
|
// httpmock answers 404 and the call degrades to the usage-only list.
|
||||||
|
let datastores = manager
|
||||||
|
.pbs_get_datastores("conn")
|
||||||
|
.await
|
||||||
|
.expect("datastores should be fetched");
|
||||||
|
assert!(datastores.is_empty());
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_datastores_merges_usage_and_config() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
server.mock(|when, then| {
|
||||||
|
when.method(GET).path("/api2/json/status/datastore-usage");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::json!({
|
||||||
|
"data": [{
|
||||||
|
"store": "backup",
|
||||||
|
"backend-type": "filesystem",
|
||||||
|
"mount-status": "mounted",
|
||||||
|
"avail": 1_200_000_000_000u64,
|
||||||
|
"total": 2_000_000_000_000u64,
|
||||||
|
"used": 800_000_000_000u64,
|
||||||
|
"gc-status": {
|
||||||
|
"disk-bytes": 100,
|
||||||
|
"disk-chunks": 2,
|
||||||
|
"cache-stats": {"hits": 7, "misses": 3},
|
||||||
|
"upid": "UPID:store:00000000:00000000:00000000:gc:backup::"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
server.mock(|when, then| {
|
||||||
|
when.method(GET).path("/api2/json/admin/datastore");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::json!({
|
||||||
|
"data": [{
|
||||||
|
"store": "backup",
|
||||||
|
"comment": "Main store",
|
||||||
|
"maintenance": "offline"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let datastores = manager
|
||||||
|
.pbs_get_datastores("conn")
|
||||||
|
.await
|
||||||
|
.expect("datastores should be fetched");
|
||||||
|
assert_eq!(datastores.len(), 1);
|
||||||
|
let datastore = &datastores[0];
|
||||||
|
assert_eq!(datastore.store, "backup");
|
||||||
|
assert_eq!(datastore.total, Some(2_000_000_000_000));
|
||||||
|
assert_eq!(datastore.used, Some(800_000_000_000));
|
||||||
|
assert_eq!(datastore.avail, Some(1_200_000_000_000));
|
||||||
|
// Merged from /admin/datastore.
|
||||||
|
assert_eq!(datastore.comment.as_deref(), Some("Main store"));
|
||||||
|
assert_eq!(datastore.maintenance.as_deref(), Some("offline"));
|
||||||
|
// Usage-derived values survive where the config entry omits the key.
|
||||||
|
assert_eq!(datastore.backend_type.as_deref(), Some("filesystem"));
|
||||||
|
assert_eq!(datastore.mount_status.as_deref(), Some("mounted"));
|
||||||
|
// gc-status.cache-stats is hoisted onto gc_status.cache_hits/misses.
|
||||||
|
let gc = datastore.gc_status.as_ref().expect("gc status should be present");
|
||||||
|
assert_eq!(gc.disk_bytes, Some(100));
|
||||||
|
assert_eq!(gc.disk_chunks, Some(2));
|
||||||
|
assert_eq!(gc.cache_hits, Some(7));
|
||||||
|
assert_eq!(gc.cache_misses, Some(3));
|
||||||
|
assert!(gc.upid.as_deref().unwrap_or("").starts_with("UPID:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_groups_hits_url_and_parses() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET).path("/api2/json/admin/datastore/backup/groups");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::json!({
|
||||||
|
"data": [{
|
||||||
|
"backup-id": "100",
|
||||||
|
"backup-type": "vm",
|
||||||
|
"backup-count": 3,
|
||||||
|
"last-backup": 1_700_000_000,
|
||||||
|
"comment": "Web server"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let groups = manager
|
||||||
|
.pbs_get_groups("conn", "backup")
|
||||||
|
.await
|
||||||
|
.expect("groups should be fetched");
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
assert_eq!(groups[0].backup_id, "100");
|
||||||
|
assert_eq!(groups[0].backup_type, "vm");
|
||||||
|
assert_eq!(groups[0].backup_count, Some(3));
|
||||||
|
assert_eq!(groups[0].last_backup, Some(1_700_000_000));
|
||||||
|
assert_eq!(groups[0].comment.as_deref(), Some("Web server"));
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_snapshots_sends_backup_query_params() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/admin/datastore/backup/snapshots")
|
||||||
|
.query_param("backup-id", "100")
|
||||||
|
.query_param("backup-type", "vm");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::json!({
|
||||||
|
"data": [{
|
||||||
|
"backup-id": "100",
|
||||||
|
"backup-type": "vm",
|
||||||
|
"backup-time": 1_700_000_000,
|
||||||
|
"size": 1_500_000_000,
|
||||||
|
"protected": true,
|
||||||
|
"comment": "Full backup",
|
||||||
|
"verification": {"state": "ok", "upid": "UPID:verify::"}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let snapshots = manager
|
||||||
|
.pbs_get_snapshots("conn", "backup", "100", "vm")
|
||||||
|
.await
|
||||||
|
.expect("snapshots should be fetched");
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
assert_eq!(snapshots[0].backup_id, "100");
|
||||||
|
assert_eq!(snapshots[0].backup_time, 1_700_000_000);
|
||||||
|
assert_eq!(snapshots[0].size, Some(1_500_000_000));
|
||||||
|
assert_eq!(snapshots[0].protected, Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
snapshots[0]
|
||||||
|
.verification
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|verification| verification.state.as_deref()),
|
||||||
|
Some("ok")
|
||||||
|
);
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_snapshot_uses_delete_verb_with_query_params() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(DELETE)
|
||||||
|
.path("/api2/json/admin/datastore/backup/snapshots")
|
||||||
|
.query_param("backup-id", "100")
|
||||||
|
.query_param("backup-type", "vm")
|
||||||
|
.query_param("backup-time", "1700000000");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":null}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
manager
|
||||||
|
.pbs_delete_snapshot("conn", "backup", "100", "vm", 1_700_000_000)
|
||||||
|
.await
|
||||||
|
.expect("snapshot should be deleted");
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_verify_posts_store_and_returns_upid() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(POST)
|
||||||
|
.path("/api2/json/admin/datastore/backup/verify")
|
||||||
|
.body_includes("store=backup");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":"UPID:store:00000000:00000000:00000000:verify:backup::"}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let upid = manager
|
||||||
|
.pbs_run_verify("conn", "backup")
|
||||||
|
.await
|
||||||
|
.expect("verify should start");
|
||||||
|
assert!(upid.starts_with("UPID:"));
|
||||||
|
assert!(upid.contains("verify"));
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_prune_sends_only_provided_keep_fields_and_dry_run() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(POST)
|
||||||
|
.path("/api2/json/admin/datastore/backup/prune-datastore")
|
||||||
|
.body_includes("store=backup")
|
||||||
|
.body_includes("keep-last=7")
|
||||||
|
.body_includes("keep-daily=14")
|
||||||
|
.body_includes("dry-run=1")
|
||||||
|
.body_excludes("keep-weekly")
|
||||||
|
.body_excludes("keep-monthly")
|
||||||
|
.body_excludes("keep-yearly");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":"UPID:store:00000000:00000000:00000000:prune:backup::"}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let upid = manager
|
||||||
|
.pbs_run_prune("conn", "backup", Some(7), Some(14), None, None, None, true)
|
||||||
|
.await
|
||||||
|
.expect("prune should start");
|
||||||
|
assert!(upid.starts_with("UPID:"));
|
||||||
|
mock.assert_calls(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_snapshot_file_streams_binary_to_disk() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let bytes: Vec<u8> = vec![0, 1, 2, 3, 250, 251, 252, 253, 254, 255];
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/admin/datastore/backup/download")
|
||||||
|
.query_param("backup-id", "100")
|
||||||
|
.query_param("backup-type", "vm")
|
||||||
|
.query_param("backup-time", "1700000000")
|
||||||
|
.query_param("file-name", "index.json.blob");
|
||||||
|
then.status(200).body(&bytes[..]);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
let save_path = dir.path().join("index.json.blob");
|
||||||
|
let save_path_str = save_path.to_str().expect("path should be UTF-8");
|
||||||
|
|
||||||
|
let returned = manager
|
||||||
|
.pbs_download_snapshot_file(
|
||||||
|
"conn",
|
||||||
|
"backup",
|
||||||
|
"100",
|
||||||
|
"vm",
|
||||||
|
1_700_000_000,
|
||||||
|
"index.json.blob",
|
||||||
|
false,
|
||||||
|
save_path_str,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("download should succeed");
|
||||||
|
assert_eq!(returned, save_path_str);
|
||||||
|
|
||||||
|
let written = std::fs::read(&save_path).expect("downloaded file should exist");
|
||||||
|
assert_eq!(written, bytes);
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_tasks_maps_pbs_worker_fields_into_task() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET).path("/api2/json/nodes/localhost/tasks");
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(
|
||||||
|
serde_json::json!({
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"upid": "UPID:localhost:00000000:00000000:00000000:verify:backup::root@pam:",
|
||||||
|
"node": "localhost",
|
||||||
|
"pid": 1234,
|
||||||
|
"pstart": 100,
|
||||||
|
"starttime": 1_700_000_000,
|
||||||
|
"endtime": 1_700_000_100,
|
||||||
|
"status": "ok",
|
||||||
|
"user": "root@pam",
|
||||||
|
"worker-id": "backup",
|
||||||
|
"worker-type": "verify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"upid": "UPID:localhost:00000000:00000000:00000000:gc:backup::root@pam:",
|
||||||
|
"node": "localhost",
|
||||||
|
"pid": 5678,
|
||||||
|
"pstart": 200,
|
||||||
|
"starttime": 1_700_000_000,
|
||||||
|
"status": "running",
|
||||||
|
"user": "root@pam",
|
||||||
|
"worker-type": "gc"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
|
||||||
|
let tasks = manager
|
||||||
|
.pbs_get_tasks("conn")
|
||||||
|
.await
|
||||||
|
.expect("tasks should be fetched");
|
||||||
|
assert_eq!(tasks.len(), 2);
|
||||||
|
|
||||||
|
// A finished task maps `status: ok` onto the PVE-style exit status.
|
||||||
|
let finished = &tasks[0];
|
||||||
|
assert_eq!(finished.r#type, "verify");
|
||||||
|
assert_eq!(finished.id, "backup");
|
||||||
|
assert_eq!(finished.node, "localhost");
|
||||||
|
assert_eq!(finished.user, "root@pam");
|
||||||
|
assert_eq!(finished.pid, 1234);
|
||||||
|
assert_eq!(finished.endtime, Some(1_700_000_100));
|
||||||
|
assert_eq!(finished.status, None);
|
||||||
|
assert_eq!(finished.exitstatus.as_deref(), Some("OK"));
|
||||||
|
|
||||||
|
// A running task keeps `status: running` and has no exit status.
|
||||||
|
let running = &tasks[1];
|
||||||
|
assert_eq!(running.r#type, "gc");
|
||||||
|
assert_eq!(running.status.as_deref(), Some("running"));
|
||||||
|
assert_eq!(running.exitstatus, None);
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_for_pbs_skips_node_discovery() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let version_mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/version")
|
||||||
|
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
|
||||||
|
});
|
||||||
|
// No `/nodes` or `/cluster/status` mocks are registered: a PVE-style
|
||||||
|
// discovery pass would hit httpmock's 404 and fail the connect.
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
let path = dir.path().join("connections.json");
|
||||||
|
let mut manager = ConnectionManager::new();
|
||||||
|
let config = ConnectionConfig {
|
||||||
|
id: "conn".to_string(),
|
||||||
|
name: "conn".to_string(),
|
||||||
|
primary: EndpointConfig {
|
||||||
|
url: server.base_url(),
|
||||||
|
node: None,
|
||||||
|
token: Some(TOKEN.to_string()),
|
||||||
|
},
|
||||||
|
fallbacks: vec![],
|
||||||
|
cert_fingerprint: None,
|
||||||
|
trusted: false,
|
||||||
|
accept_untrusted: true,
|
||||||
|
status: "disconnected".to_string(),
|
||||||
|
cluster_name: None,
|
||||||
|
is_cluster: false,
|
||||||
|
auth_mode: "token".to_string(),
|
||||||
|
username: None,
|
||||||
|
nodes: vec![],
|
||||||
|
cluster_id: None,
|
||||||
|
server_type: "pbs".to_string(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.add_connection(config, &path)
|
||||||
|
.await
|
||||||
|
.expect("connection should be added");
|
||||||
|
|
||||||
|
let result = manager
|
||||||
|
.connect("conn", &path)
|
||||||
|
.await
|
||||||
|
.expect("PBS connect should succeed with only /version mocked");
|
||||||
|
assert_eq!(result.status, "connected");
|
||||||
|
assert_eq!(result.merged_into, None);
|
||||||
|
assert_eq!(result.connection_id, "conn");
|
||||||
|
version_mock.assert_calls(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_with_token_validates_against_pbs_version_endpoint() {
|
||||||
|
let server = MockServer::start();
|
||||||
|
let mock = server.mock(|when, then| {
|
||||||
|
when.method(GET)
|
||||||
|
.path("/api2/json/version")
|
||||||
|
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
|
||||||
|
then.status(200)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
|
||||||
|
});
|
||||||
|
|
||||||
|
let manager = ConnectionManager::new();
|
||||||
|
let result = manager
|
||||||
|
.login_with_token(&server.base_url(), TOKEN, "pbs")
|
||||||
|
.await
|
||||||
|
.expect("login should validate the token against /version");
|
||||||
|
assert!(!result.connection_id.is_empty());
|
||||||
|
assert_eq!(result.ticket, TOKEN);
|
||||||
|
mock.assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_structs_serialize_to_camel_case_shapes() {
|
||||||
|
// The frontend types (`src/types/pbs.ts`) mirror the backend structs'
|
||||||
|
// camelCase serialization; these assertions guard that contract for every
|
||||||
|
// struct that deserializes from kebab-case raw JSON.
|
||||||
|
let snapshot = clustri::PbsSnapshot {
|
||||||
|
backup_id: "100".to_string(),
|
||||||
|
backup_type: "vm".to_string(),
|
||||||
|
backup_time: 1_700_000_000,
|
||||||
|
size: Some(1_500_000_000),
|
||||||
|
protected: Some(true),
|
||||||
|
comment: None,
|
||||||
|
files: Some(vec!["index.json.blob".to_string()]),
|
||||||
|
fingerprint: None,
|
||||||
|
owner: None,
|
||||||
|
verification: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&snapshot).expect("snapshot should serialize");
|
||||||
|
assert_eq!(json["backupId"], "100");
|
||||||
|
assert_eq!(json["backupType"], "vm");
|
||||||
|
assert_eq!(json["backupTime"], 1_700_000_000);
|
||||||
|
assert_eq!(json["size"], 1_500_000_000);
|
||||||
|
assert_eq!(json["protected"], true);
|
||||||
|
assert_eq!(json["files"][0], "index.json.blob");
|
||||||
|
|
||||||
|
let job = clustri::PbsJob {
|
||||||
|
id: "verify-1".to_string(),
|
||||||
|
store: Some("backup".to_string()),
|
||||||
|
schedule: None,
|
||||||
|
comment: None,
|
||||||
|
disable: None,
|
||||||
|
last_run_state: Some("OK".to_string()),
|
||||||
|
last_run_endtime: Some(1_700_000_000),
|
||||||
|
next_run: None,
|
||||||
|
keep_last: Some(7),
|
||||||
|
keep_daily: None,
|
||||||
|
keep_weekly: None,
|
||||||
|
keep_monthly: None,
|
||||||
|
keep_yearly: None,
|
||||||
|
ignore_verified: None,
|
||||||
|
max_depth: Some(5),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&job).expect("job should serialize");
|
||||||
|
assert_eq!(json["lastRunState"], "OK");
|
||||||
|
assert_eq!(json["lastRunEndtime"], 1_700_000_000);
|
||||||
|
assert_eq!(json["keepLast"], 7);
|
||||||
|
assert_eq!(json["maxDepth"], 5);
|
||||||
|
|
||||||
|
// Deserialize from the raw kebab-case wire shape, then verify the
|
||||||
|
// serialized shape is camelCase (the same round-trip the API requests go
|
||||||
|
// through).
|
||||||
|
let node_status: clustri::PbsNodeStatus = serde_json::from_value(serde_json::json!({
|
||||||
|
"cpu": 0.15,
|
||||||
|
"current-kernel": {"machine": "x86_64", "release": "6.8.12-4-pve", "sysname": "Linux"}
|
||||||
|
}))
|
||||||
|
.expect("node status should deserialize from kebab-case keys");
|
||||||
|
let json = serde_json::to_value(&node_status).expect("node status should serialize");
|
||||||
|
assert_eq!(json["cpu"], 0.15);
|
||||||
|
assert_eq!(json["currentKernel"]["release"], "6.8.12-4-pve");
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ async fn setup_manager(
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ fn token_config(id: &str, url: &str, fallbacks: Vec<EndpointConfig>) -> Connecti
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ async fn setup_manager(
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use httpmock::prelude::*;
|
|||||||
use httpmock::Mock;
|
use httpmock::Mock;
|
||||||
use clustri::{
|
use clustri::{
|
||||||
api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error,
|
api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error,
|
||||||
|
ServerType,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use reqwest::Method as RMethod;
|
use reqwest::Method as RMethod;
|
||||||
@@ -32,6 +33,7 @@ fn token_auth() -> AuthContext {
|
|||||||
token: Some(TOKEN.to_string()),
|
token: Some(TOKEN.to_string()),
|
||||||
ticket: None,
|
ticket: None,
|
||||||
csrf_token: None,
|
csrf_token: None,
|
||||||
|
server_type: ServerType::Pve,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ async fn setup_manager_with_fallbacks(
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use clustri::{
|
use clustri::{
|
||||||
api_request, derive_node_url, AuthContext, AuthMode, ConnectionConfig, ConnectionManager,
|
api_request, derive_node_url, AuthContext, AuthMode, ConnectionConfig, ConnectionManager,
|
||||||
EndpointConfig,
|
EndpointConfig, ServerType,
|
||||||
};
|
};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
|
||||||
@@ -136,6 +136,7 @@ async fn manager_with_session(
|
|||||||
username: Some(user.to_string()),
|
username: Some(user.to_string()),
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
@@ -155,6 +156,7 @@ fn ticket_auth(ticket: &str) -> AuthContext {
|
|||||||
token: None,
|
token: None,
|
||||||
ticket: Some(ticket.to_string()),
|
ticket: Some(ticket.to_string()),
|
||||||
csrf_token: None,
|
csrf_token: None,
|
||||||
|
server_type: ServerType::Pve,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +911,7 @@ async fn live_failover_to_fallback() {
|
|||||||
username: Some(user.to_string()),
|
username: Some(user.to_string()),
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
@@ -993,6 +996,7 @@ async fn live_cluster_identity_consistent_across_nodes() {
|
|||||||
username: Some(user.to_string()),
|
username: Some(user.to_string()),
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> Con
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ async fn persisted_configs_round_trip_without_secrets() {
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.add_connection(config, &path)
|
.add_connection(config, &path)
|
||||||
@@ -216,6 +218,7 @@ async fn update_connection_preserves_cert_settings_and_replaces_config() {
|
|||||||
username: None,
|
username: None,
|
||||||
nodes: vec![],
|
nodes: vec![],
|
||||||
cluster_id: None,
|
cluster_id: None,
|
||||||
|
server_type: "pve".to_string(),
|
||||||
};
|
};
|
||||||
manager
|
manager
|
||||||
.update_connection(updated, &path)
|
.update_connection(updated, &path)
|
||||||
|
|||||||
+45
@@ -12,6 +12,9 @@ import { BackupList } from '@/components/backups/BackupList'
|
|||||||
import { CommandPalette } from '@/components/command/CommandPalette'
|
import { CommandPalette } from '@/components/command/CommandPalette'
|
||||||
import { StorageOverview } from '@/components/storage/StorageOverview'
|
import { StorageOverview } from '@/components/storage/StorageOverview'
|
||||||
import { StorageDetail } from '@/components/storage/StorageDetail'
|
import { StorageDetail } from '@/components/storage/StorageDetail'
|
||||||
|
import { PbsOverview } from '@/components/pbs/PbsOverview'
|
||||||
|
import { PbsDatastores } from '@/components/pbs/PbsDatastores'
|
||||||
|
import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail'
|
||||||
import { SettingsPage } from '@/components/settings/SettingsPage'
|
import { SettingsPage } from '@/components/settings/SettingsPage'
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
import { ToastProvider, useToast } from '@/components/ui/toast'
|
import { ToastProvider, useToast } from '@/components/ui/toast'
|
||||||
@@ -43,6 +46,9 @@ type View =
|
|||||||
| { type: 'backups' }
|
| { type: 'backups' }
|
||||||
| { type: 'storage' }
|
| { type: 'storage' }
|
||||||
| { type: 'storage-detail'; storage: string; node: string }
|
| { type: 'storage-detail'; storage: string; node: string }
|
||||||
|
| { type: 'pbs-overview' }
|
||||||
|
| { type: 'pbs-datastores' }
|
||||||
|
| { type: 'pbs-datastore-detail'; store: string }
|
||||||
| { type: 'settings' }
|
| { type: 'settings' }
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
@@ -317,6 +323,23 @@ function AppContent() {
|
|||||||
onBack={() => handleNavigate({ type: 'storage' })}
|
onBack={() => handleNavigate({ type: 'storage' })}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case 'pbs-overview':
|
||||||
|
return <PbsOverview connectionId={activeConnectionId} />
|
||||||
|
case 'pbs-datastores':
|
||||||
|
return (
|
||||||
|
<PbsDatastores
|
||||||
|
connectionId={activeConnectionId}
|
||||||
|
onDatastoreClick={(store) => handleNavigate({ type: 'pbs-datastore-detail', store })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'pbs-datastore-detail':
|
||||||
|
return (
|
||||||
|
<PbsDatastoreDetail
|
||||||
|
connectionId={activeConnectionId}
|
||||||
|
store={view.store}
|
||||||
|
onBack={() => handleNavigate({ type: 'pbs-datastores' })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
@@ -328,6 +351,28 @@ function AppContent() {
|
|||||||
|
|
||||||
const activeConnection = connections.find((c) => c.id === activeConnectionId)
|
const activeConnection = connections.find((c) => c.id === activeConnectionId)
|
||||||
|
|
||||||
|
// When the active connection switches to a PBS server, leave any VE-only
|
||||||
|
// view behind and land on the PBS overview. Shared views (tasks, settings)
|
||||||
|
// and PBS views are left untouched.
|
||||||
|
const activeServerType = activeConnection?.serverType ?? 'pve'
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeServerType !== 'pbs') return
|
||||||
|
const veOnlyViews = new Set([
|
||||||
|
'dashboard',
|
||||||
|
'vms',
|
||||||
|
'vm-detail',
|
||||||
|
'nodes',
|
||||||
|
'node-detail',
|
||||||
|
'containers',
|
||||||
|
'backups',
|
||||||
|
'storage',
|
||||||
|
'storage-detail',
|
||||||
|
])
|
||||||
|
if (veOnlyViews.has(view.type)) {
|
||||||
|
setView({ type: 'pbs-overview' })
|
||||||
|
}
|
||||||
|
}, [activeConnectionId, activeServerType, view.type])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
|
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ type View =
|
|||||||
| { type: 'backups' }
|
| { type: 'backups' }
|
||||||
| { type: 'storage' }
|
| { type: 'storage' }
|
||||||
| { type: 'storage-detail'; storage: string; node: string }
|
| { type: 'storage-detail'; storage: string; node: string }
|
||||||
|
| { type: 'pbs-overview' }
|
||||||
|
| { type: 'pbs-datastores' }
|
||||||
|
| { type: 'pbs-datastore-detail'; store: string }
|
||||||
| { type: 'settings' }
|
| { type: 'settings' }
|
||||||
|
|
||||||
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
|
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
|
||||||
@@ -194,8 +197,9 @@ export function CommandPalette({
|
|||||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||||
const connections = useConnectionStore((s) => s.connections)
|
const connections = useConnectionStore((s) => s.connections)
|
||||||
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
|
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
|
||||||
|
const serverType = connections.find((c) => c.id === activeConnectionId)?.serverType ?? 'pve'
|
||||||
|
|
||||||
const { data: vms = [] } = useVMs(activeConnectionId)
|
const { data: vms = [] } = useVMs(serverType === 'pbs' ? null : activeConnectionId)
|
||||||
|
|
||||||
// VM mutation hooks
|
// VM mutation hooks
|
||||||
const startVM = useStartVM()
|
const startVM = useStartVM()
|
||||||
@@ -210,128 +214,179 @@ export function CommandPalette({
|
|||||||
const items: CommandItem[] = []
|
const items: CommandItem[] = []
|
||||||
|
|
||||||
// -- Navigation --
|
// -- Navigation --
|
||||||
items.push(
|
if (serverType === 'pbs') {
|
||||||
{
|
items.push(
|
||||||
id: 'nav-dashboard',
|
{
|
||||||
label: 'Go to Dashboard',
|
id: 'nav-pbs-overview',
|
||||||
icon: LayoutDashboard,
|
label: 'Go to PBS Overview',
|
||||||
category: 'navigation',
|
icon: LayoutDashboard,
|
||||||
shortcut: '⌘1',
|
category: 'navigation',
|
||||||
keywords: ['dashboard', 'home', 'overview'],
|
shortcut: '⌘1',
|
||||||
onExecute: () => onNavigate({ type: 'dashboard' }),
|
keywords: ['overview', 'pbs', 'backup server', 'dashboard', 'home'],
|
||||||
},
|
onExecute: () => onNavigate({ type: 'pbs-overview' }),
|
||||||
{
|
},
|
||||||
id: 'nav-vms',
|
{
|
||||||
label: 'Go to VMs',
|
id: 'nav-pbs-datastores',
|
||||||
icon: Box,
|
label: 'Go to Datastores',
|
||||||
category: 'navigation',
|
icon: HardDrive,
|
||||||
shortcut: '⌘2',
|
category: 'navigation',
|
||||||
keywords: ['vm', 'vms', 'virtual machines', 'containers'],
|
shortcut: '⌘2',
|
||||||
onExecute: () => onNavigate({ type: 'vms' }),
|
keywords: ['datastores', 'datastore', 'storage', 'backups'],
|
||||||
},
|
onExecute: () => onNavigate({ type: 'pbs-datastores' }),
|
||||||
{
|
},
|
||||||
id: 'nav-tasks',
|
{
|
||||||
label: 'Go to Tasks',
|
id: 'nav-tasks',
|
||||||
icon: ListTodo,
|
label: 'Go to Tasks',
|
||||||
category: 'navigation',
|
icon: ListTodo,
|
||||||
shortcut: '⌘3',
|
category: 'navigation',
|
||||||
keywords: ['tasks', 'jobs', 'queue'],
|
shortcut: '⌘3',
|
||||||
onExecute: () => onNavigate({ type: 'tasks' }),
|
keywords: ['tasks', 'jobs', 'queue'],
|
||||||
},
|
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||||
{
|
},
|
||||||
id: 'nav-backups',
|
{
|
||||||
label: 'Go to Backups',
|
id: 'nav-settings',
|
||||||
icon: Shield,
|
label: 'Go to Settings',
|
||||||
category: 'navigation',
|
icon: Settings,
|
||||||
shortcut: '⌘4',
|
category: 'navigation',
|
||||||
keywords: ['backups', 'restore', 'backup'],
|
shortcut: '⌘6',
|
||||||
onExecute: () => onNavigate({ type: 'backups' }),
|
keywords: ['settings', 'preferences', 'configuration'],
|
||||||
},
|
onExecute: () => onNavigate({ type: 'settings' }),
|
||||||
{
|
},
|
||||||
id: 'nav-storage',
|
{
|
||||||
label: 'Go to Storage',
|
id: 'nav-add-connection',
|
||||||
icon: HardDrive,
|
label: 'Add Connection',
|
||||||
category: 'navigation',
|
icon: Plus,
|
||||||
shortcut: '⌘5',
|
category: 'navigation',
|
||||||
keywords: ['storage', 'disks', 'volumes'],
|
keywords: ['add', 'connection', 'server', 'proxmox', 'pbs', 'new'],
|
||||||
onExecute: () => onNavigate({ type: 'storage' }),
|
onExecute: () => onAddConnection(),
|
||||||
},
|
},
|
||||||
{
|
)
|
||||||
id: 'nav-settings',
|
} else {
|
||||||
label: 'Go to Settings',
|
items.push(
|
||||||
icon: Settings,
|
{
|
||||||
category: 'navigation',
|
id: 'nav-dashboard',
|
||||||
shortcut: '⌘6',
|
label: 'Go to Dashboard',
|
||||||
keywords: ['settings', 'preferences', 'configuration'],
|
icon: LayoutDashboard,
|
||||||
onExecute: () => onNavigate({ type: 'settings' }),
|
category: 'navigation',
|
||||||
},
|
shortcut: '⌘1',
|
||||||
{
|
keywords: ['dashboard', 'home', 'overview'],
|
||||||
id: 'nav-add-connection',
|
onExecute: () => onNavigate({ type: 'dashboard' }),
|
||||||
label: 'Add Connection',
|
},
|
||||||
icon: Plus,
|
{
|
||||||
category: 'navigation',
|
id: 'nav-vms',
|
||||||
keywords: ['add', 'connection', 'server', 'proxmox', 'new'],
|
label: 'Go to VMs',
|
||||||
onExecute: () => onAddConnection(),
|
icon: Box,
|
||||||
},
|
category: 'navigation',
|
||||||
)
|
shortcut: '⌘2',
|
||||||
|
keywords: ['vm', 'vms', 'virtual machines', 'containers'],
|
||||||
// -- VMs --
|
onExecute: () => onNavigate({ type: 'vms' }),
|
||||||
for (const vm of vms) {
|
},
|
||||||
items.push({
|
{
|
||||||
id: `vm-detail-${vm.vmid}`,
|
id: 'nav-tasks',
|
||||||
label: vm.name,
|
label: 'Go to Tasks',
|
||||||
description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`,
|
icon: ListTodo,
|
||||||
icon: Server,
|
category: 'navigation',
|
||||||
category: 'vms',
|
shortcut: '⌘3',
|
||||||
keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status],
|
keywords: ['tasks', 'jobs', 'queue'],
|
||||||
onExecute: () => onNavigate({ type: 'vm-detail', vm }),
|
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-backups',
|
||||||
|
label: 'Go to Backups',
|
||||||
|
icon: Shield,
|
||||||
|
category: 'navigation',
|
||||||
|
shortcut: '⌘4',
|
||||||
|
keywords: ['backups', 'restore', 'backup'],
|
||||||
|
onExecute: () => onNavigate({ type: 'backups' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-storage',
|
||||||
|
label: 'Go to Storage',
|
||||||
|
icon: HardDrive,
|
||||||
|
category: 'navigation',
|
||||||
|
shortcut: '⌘5',
|
||||||
|
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',
|
||||||
|
icon: Plus,
|
||||||
|
category: 'navigation',
|
||||||
|
keywords: ['add', 'connection', 'server', 'proxmox', 'new'],
|
||||||
|
onExecute: () => onAddConnection(),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- VM Actions --
|
// -- VMs (not applicable to PBS servers) --
|
||||||
for (const vm of vms) {
|
if (serverType !== 'pbs') {
|
||||||
if (vm.status === 'running') {
|
for (const vm of vms) {
|
||||||
items.push(
|
|
||||||
{
|
|
||||||
id: `action-stop-${vm.vmid}`,
|
|
||||||
label: `Stop ${vm.name}`,
|
|
||||||
description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
|
||||||
icon: Square,
|
|
||||||
category: 'actions',
|
|
||||||
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
|
|
||||||
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `action-shutdown-${vm.vmid}`,
|
|
||||||
label: `Shutdown ${vm.name}`,
|
|
||||||
description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
|
||||||
icon: Power,
|
|
||||||
category: 'actions',
|
|
||||||
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
|
|
||||||
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `action-reboot-${vm.vmid}`,
|
|
||||||
label: `Reboot ${vm.name}`,
|
|
||||||
description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
|
||||||
icon: RotateCw,
|
|
||||||
category: 'actions',
|
|
||||||
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
|
|
||||||
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
|
|
||||||
items.push({
|
items.push({
|
||||||
id: `action-start-${vm.vmid}`,
|
id: `vm-detail-${vm.vmid}`,
|
||||||
label: `Start ${vm.name}`,
|
label: vm.name,
|
||||||
description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`,
|
||||||
icon: Play,
|
icon: Server,
|
||||||
category: 'actions',
|
category: 'vms',
|
||||||
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
|
keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status],
|
||||||
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
onExecute: () => onNavigate({ type: 'vm-detail', vm }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- VM Actions --
|
||||||
|
for (const vm of vms) {
|
||||||
|
if (vm.status === 'running') {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
id: `action-stop-${vm.vmid}`,
|
||||||
|
label: `Stop ${vm.name}`,
|
||||||
|
description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||||
|
icon: Square,
|
||||||
|
category: 'actions',
|
||||||
|
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
|
||||||
|
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `action-shutdown-${vm.vmid}`,
|
||||||
|
label: `Shutdown ${vm.name}`,
|
||||||
|
description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||||
|
icon: Power,
|
||||||
|
category: 'actions',
|
||||||
|
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
|
||||||
|
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `action-reboot-${vm.vmid}`,
|
||||||
|
label: `Reboot ${vm.name}`,
|
||||||
|
description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||||
|
icon: RotateCw,
|
||||||
|
category: 'actions',
|
||||||
|
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
|
||||||
|
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (vm.status === 'stopped' || vm.status === 'paused' || vm.status === 'suspended') {
|
||||||
|
items.push({
|
||||||
|
id: `action-start-${vm.vmid}`,
|
||||||
|
label: `Start ${vm.name}`,
|
||||||
|
description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||||
|
icon: Play,
|
||||||
|
category: 'actions',
|
||||||
|
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
|
||||||
|
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Connections --
|
// -- Connections --
|
||||||
@@ -351,6 +406,7 @@ export function CommandPalette({
|
|||||||
}, [
|
}, [
|
||||||
vms,
|
vms,
|
||||||
connections,
|
connections,
|
||||||
|
serverType,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onAddConnection,
|
onAddConnection,
|
||||||
startVM,
|
startVM,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { ShieldAlert, Loader2 } from 'lucide-react'
|
import { ShieldAlert, Loader2 } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useConnectionStore } from '@/stores/connectionStore'
|
import { useConnectionStore } from '@/stores/connectionStore'
|
||||||
import { useToast } from '@/components/ui/toast'
|
import { useToast } from '@/components/ui/toast'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,7 @@ import type {
|
|||||||
AuthMode,
|
AuthMode,
|
||||||
CertificateInfo,
|
CertificateInfo,
|
||||||
LoginResult,
|
LoginResult,
|
||||||
|
ServerType,
|
||||||
} from '@/types/connection'
|
} from '@/types/connection'
|
||||||
|
|
||||||
interface ConnectionDialogProps {
|
interface ConnectionDialogProps {
|
||||||
@@ -51,6 +53,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
|
|
||||||
const [step, setStep] = useState<DialogStep>('credentials')
|
const [step, setStep] = useState<DialogStep>('credentials')
|
||||||
const [authMode, setAuthMode] = useState<AuthMode>('password')
|
const [authMode, setAuthMode] = useState<AuthMode>('password')
|
||||||
|
const [serverType, setServerType] = useState<ServerType>('pve')
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
@@ -63,6 +66,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setStep('credentials')
|
setStep('credentials')
|
||||||
|
setServerType('pve')
|
||||||
setName('')
|
setName('')
|
||||||
setUrl('')
|
setUrl('')
|
||||||
setUsername('')
|
setUsername('')
|
||||||
@@ -130,7 +134,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
await loginWithPassword(cleanUrl, username, password)
|
await loginWithPassword(cleanUrl, username, password)
|
||||||
}
|
}
|
||||||
} else if (apiToken) {
|
} else if (apiToken) {
|
||||||
await loginWithToken(cleanUrl, apiToken)
|
await loginWithToken(cleanUrl, apiToken, editing.serverType ?? 'pve')
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: ConnectionConfig = {
|
const config: ConnectionConfig = {
|
||||||
@@ -148,6 +152,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
isCluster: editing.isCluster,
|
isCluster: editing.isCluster,
|
||||||
authMode,
|
authMode,
|
||||||
username: authMode === 'password' ? username : undefined,
|
username: authMode === 'password' ? username : undefined,
|
||||||
|
serverType: editing.serverType ?? 'pve',
|
||||||
nodes: editing.nodes,
|
nodes: editing.nodes,
|
||||||
clusterId: editing.clusterId,
|
clusterId: editing.clusterId,
|
||||||
}
|
}
|
||||||
@@ -191,7 +196,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
if (!apiToken) {
|
if (!apiToken) {
|
||||||
throw new Error('API token is required')
|
throw new Error('API token is required')
|
||||||
}
|
}
|
||||||
result = await loginWithToken(cleanUrl, apiToken)
|
result = await loginWithToken(cleanUrl, apiToken, serverType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
@@ -211,6 +216,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
isCluster: false,
|
isCluster: false,
|
||||||
authMode,
|
authMode,
|
||||||
username: authMode === 'password' ? username : undefined,
|
username: authMode === 'password' ? username : undefined,
|
||||||
|
serverType,
|
||||||
}
|
}
|
||||||
|
|
||||||
await addConnection(config)
|
await addConnection(config)
|
||||||
@@ -260,6 +266,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
isCluster: false,
|
isCluster: false,
|
||||||
authMode,
|
authMode,
|
||||||
username: authMode === 'password' ? username : undefined,
|
username: authMode === 'password' ? username : undefined,
|
||||||
|
serverType,
|
||||||
}
|
}
|
||||||
|
|
||||||
// The backend requires the connection to exist before pinning its
|
// The backend requires the connection to exist before pinning its
|
||||||
@@ -316,14 +323,58 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
{step === 'credentials' && (
|
{step === 'credentials' && (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editing ? 'Edit Connection' : 'Connect to Proxmox'}</DialogTitle>
|
<DialogTitle>
|
||||||
|
{editing
|
||||||
|
? 'Edit Connection'
|
||||||
|
: serverType === 'pbs'
|
||||||
|
? 'Connect to Proxmox Backup Server'
|
||||||
|
: 'Connect to Proxmox'}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editing
|
{editing
|
||||||
? 'Update this connection. The server URL cannot be changed; add a new connection to target a different server.'
|
? 'Update this connection. The server URL cannot be changed; add a new connection to target a different server.'
|
||||||
: 'Sign in with your Proxmox credentials or API token'}
|
: serverType === 'pbs'
|
||||||
|
? 'Sign in with your Proxmox Backup Server credentials or API token'
|
||||||
|
: 'Sign in with your Proxmox credentials or API token'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-type">Server Type</Label>
|
||||||
|
<div
|
||||||
|
id="server-type"
|
||||||
|
role="group"
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setServerType('pve')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
serverType === 'pve'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Proxmox VE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setServerType('pbs')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
serverType === 'pbs'
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Proxmox Backup Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
|
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
|
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
|
||||||
@@ -335,7 +386,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
<Label htmlFor="url">Server URL</Label>
|
<Label htmlFor="url">Server URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="url"
|
id="url"
|
||||||
placeholder="https://192.168.1.10:8006"
|
placeholder={serverType === 'pbs' ? 'https://192.168.1.10:8007' : 'https://192.168.1.10:8006'}
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
required
|
required
|
||||||
@@ -345,7 +396,9 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{editing
|
{editing
|
||||||
? 'Server URL cannot be changed while editing'
|
? 'Server URL cannot be changed while editing'
|
||||||
: 'The URL of your Proxmox server (must use HTTPS)'}
|
: serverType === 'pbs'
|
||||||
|
? 'The URL of your Proxmox Backup Server (must use HTTPS)'
|
||||||
|
: 'The URL of your Proxmox server (must use HTTPS)'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,7 +500,7 @@ export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDial
|
|||||||
<ShieldAlert className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
<ShieldAlert className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
This is a self-signed or untrusted certificate. Verify the fingerprint
|
This is a self-signed or untrusted certificate. Verify the fingerprint
|
||||||
against your Proxmox server's SSL certificate before trusting.
|
against your server's SSL certificate before trusting.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings, Hexagon } from 'lucide-react'
|
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings, Hexagon } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings'
|
type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings' | 'pbs-overview' | 'pbs-datastores' | 'pbs-datastore-detail'
|
||||||
|
|
||||||
type NavigationTarget =
|
type NavigationTarget =
|
||||||
| { type: ViewType }
|
| { type: ViewType }
|
||||||
@@ -77,34 +77,54 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
|||||||
<p className="ml-7 mt-1 text-[10px] text-muted-foreground">Offline</p>
|
<p className="ml-7 mt-1 text-[10px] text-muted-foreground">Offline</p>
|
||||||
)}
|
)}
|
||||||
<div className="ml-4 mt-1 space-y-0.5">
|
<div className="ml-4 mt-1 space-y-0.5">
|
||||||
<SidebarItem
|
{connection.serverType === 'pbs' ? (
|
||||||
icon={LayoutDashboard}
|
<>
|
||||||
label="Dashboard"
|
<SidebarItem
|
||||||
active={activeView === 'dashboard'}
|
icon={LayoutDashboard}
|
||||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
label="Overview"
|
||||||
/>
|
active={activeView === 'pbs-overview'}
|
||||||
<SidebarItem
|
onClick={() => onNavigate?.({ type: 'pbs-overview' })}
|
||||||
icon={Server}
|
/>
|
||||||
label="Nodes"
|
<SidebarItem
|
||||||
active={activeView === 'nodes'}
|
icon={HardDrive}
|
||||||
onClick={() => onNavigate?.({ type: 'nodes' })}
|
label="Datastores"
|
||||||
/>
|
active={activeView === 'pbs-datastores' || activeView === 'pbs-datastore-detail'}
|
||||||
<SidebarItem
|
onClick={() => onNavigate?.({ type: 'pbs-datastores' })}
|
||||||
icon={Box}
|
/>
|
||||||
label="VMs"
|
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
</>
|
||||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
) : (
|
||||||
/>
|
<>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Box}
|
icon={LayoutDashboard}
|
||||||
label="Containers"
|
label="Dashboard"
|
||||||
active={activeView === 'containers'}
|
active={activeView === 'dashboard'}
|
||||||
onClick={() => onNavigate?.({ type: 'containers' })}
|
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||||
/>
|
/>
|
||||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
<SidebarItem
|
||||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
icon={Server}
|
||||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
label="Nodes"
|
||||||
{connection.nodes && connection.nodes.length > 0 && (
|
active={activeView === 'nodes'}
|
||||||
|
onClick={() => onNavigate?.({ type: 'nodes' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={Box}
|
||||||
|
label="VMs"
|
||||||
|
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||||
|
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||||
|
/>
|
||||||
|
<SidebarItem
|
||||||
|
icon={Box}
|
||||||
|
label="Containers"
|
||||||
|
active={activeView === 'containers'}
|
||||||
|
onClick={() => onNavigate?.({ type: 'containers' })}
|
||||||
|
/>
|
||||||
|
<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' })} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Cluster nodes
|
Cluster nodes
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
Eraser,
|
||||||
|
HardDrive,
|
||||||
|
Recycle,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
usePbsDatastores,
|
||||||
|
usePbsGroups,
|
||||||
|
usePbsSnapshots,
|
||||||
|
usePbsVerifyJobs,
|
||||||
|
usePbsPruneJobs,
|
||||||
|
usePbsGcJobs,
|
||||||
|
usePbsDeleteSnapshot,
|
||||||
|
usePbsDeleteGroup,
|
||||||
|
queryKeys,
|
||||||
|
} from '@/hooks/usePbs'
|
||||||
|
import { VerifyDialog } from '@/components/pbs/dialogs/VerifyDialog'
|
||||||
|
import { PruneDialog } from '@/components/pbs/dialogs/PruneDialog'
|
||||||
|
import { GcDialog } from '@/components/pbs/dialogs/GcDialog'
|
||||||
|
import { DownloadFilesDialog } from '@/components/pbs/dialogs/DownloadFilesDialog'
|
||||||
|
import { formatBytes } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { PbsBackupGroup, PbsJob, PbsSnapshot } from '@/types/pbs'
|
||||||
|
|
||||||
|
interface PbsDatastoreDetailProps {
|
||||||
|
connectionId: string
|
||||||
|
store: string
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(seconds?: number): string {
|
||||||
|
if (!seconds) return 'N/A'
|
||||||
|
return new Date(seconds * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageColor(percent: number): string {
|
||||||
|
if (percent >= 90) return 'bg-destructive'
|
||||||
|
if (percent >= 70) return 'bg-warning'
|
||||||
|
return 'bg-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastRunStateBadge({ state }: { state?: string }) {
|
||||||
|
if (!state) {
|
||||||
|
return <span className="text-xs text-muted-foreground">—</span>
|
||||||
|
}
|
||||||
|
const ok = state.toUpperCase() === 'OK'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
|
||||||
|
ok
|
||||||
|
? 'border-success/25 bg-success/10 text-success'
|
||||||
|
: 'border-destructive/25 bg-destructive/10 text-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ok ? <CheckCircle className="h-3 w-3" /> : <XCircle className="h-3 w-3" />}
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobTable({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
jobs,
|
||||||
|
showKeep,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: LucideIcon
|
||||||
|
jobs?: PbsJob[]
|
||||||
|
showKeep?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">{title}</h3>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{!jobs || jobs.length === 0 ? (
|
||||||
|
<EmptyState icon={Icon} title={`No ${title.toLowerCase()} configured`} />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">ID</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Store</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Schedule</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Last Run</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Next Run</th>
|
||||||
|
{showKeep && (
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Retention</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<tr key={job.id} className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{job.id}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{job.store ?? '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{job.schedule ?? '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<LastRunStateBadge state={job.lastRunState} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatTimestamp(job.nextRun)}
|
||||||
|
</td>
|
||||||
|
{showKeep && (
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{[
|
||||||
|
job.keepLast != null && `last ${job.keepLast}`,
|
||||||
|
job.keepDaily != null && `daily ${job.keepDaily}`,
|
||||||
|
job.keepWeekly != null && `weekly ${job.keepWeekly}`,
|
||||||
|
job.keepMonthly != null && `monthly ${job.keepMonthly}`,
|
||||||
|
job.keepYearly != null && `yearly ${job.keepYearly}`,
|
||||||
|
]
|
||||||
|
.filter((part): part is string => !!part)
|
||||||
|
.join(' · ') || '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PbsDatastoreDetail({ connectionId, store, onBack }: PbsDatastoreDetailProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<PbsBackupGroup | null>(null)
|
||||||
|
const [verifyOpen, setVerifyOpen] = useState(false)
|
||||||
|
const [pruneOpen, setPruneOpen] = useState(false)
|
||||||
|
const [gcOpen, setGcOpen] = useState(false)
|
||||||
|
const [downloadSnapshot, setDownloadSnapshot] = useState<PbsSnapshot | null>(null)
|
||||||
|
const [confirmDeleteSnapshot, setConfirmDeleteSnapshot] = useState<PbsSnapshot | null>(null)
|
||||||
|
const [confirmDeleteGroup, setConfirmDeleteGroup] = useState(false)
|
||||||
|
|
||||||
|
const { data: datastores } = usePbsDatastores(connectionId)
|
||||||
|
const datastore = datastores?.find((d) => d.store === store)
|
||||||
|
|
||||||
|
const { data: groups, isLoading: groupsLoading, error: groupsError } = usePbsGroups(connectionId, store)
|
||||||
|
const {
|
||||||
|
data: snapshots,
|
||||||
|
isLoading: snapshotsLoading,
|
||||||
|
} = usePbsSnapshots(
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
selectedGroup?.backupId ?? null,
|
||||||
|
selectedGroup?.backupType ?? null,
|
||||||
|
)
|
||||||
|
const { data: verifyJobs } = usePbsVerifyJobs(connectionId)
|
||||||
|
const { data: pruneJobs } = usePbsPruneJobs(connectionId)
|
||||||
|
const { data: gcJobs } = usePbsGcJobs(connectionId)
|
||||||
|
|
||||||
|
const deleteSnapshot = usePbsDeleteSnapshot()
|
||||||
|
const deleteGroup = usePbsDeleteGroup()
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connectionId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connectionId, store) })
|
||||||
|
if (selectedGroup) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.pbsSnapshots(
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
selectedGroup.backupId,
|
||||||
|
selectedGroup.backupType,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connectionId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connectionId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connectionId) })
|
||||||
|
}, [queryClient, connectionId, store, selectedGroup])
|
||||||
|
|
||||||
|
if (groupsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-9 w-9" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-7 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-56 w-full" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupsError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-destructive">Failed to load backup groups</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = datastore?.total && datastore.total > 0
|
||||||
|
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||||
|
: 0
|
||||||
|
const usageColor = getUsageColor(percent)
|
||||||
|
const hasError = !!datastore?.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-6 w-6 shrink-0 text-muted-foreground" />
|
||||||
|
<h2 className="font-mono text-2xl font-semibold tracking-tight">{store}</h2>
|
||||||
|
{hasError && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{datastore?.maintenance && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||||
|
Maintenance
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Datastore overview</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datastore usage + actions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Usage</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setVerifyOpen(true)}>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setPruneOpen(true)}>
|
||||||
|
<Eraser className="h-3.5 w-3.5" />
|
||||||
|
Prune
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={hasError} onClick={() => setGcOpen(true)}>
|
||||||
|
<Recycle className="h-3.5 w-3.5" />
|
||||||
|
Garbage Collection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{hasError && (
|
||||||
|
<p className="text-sm text-destructive">{datastore.error}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Disk Usage</span>
|
||||||
|
<span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>{formatBytes(datastore?.used ?? 0)} used</span>
|
||||||
|
<span>{formatBytes(datastore?.total ?? 0)} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatBytes(datastore?.avail ?? 0)} available
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Groups / Snapshots drill-down */}
|
||||||
|
{selectedGroup ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setSelectedGroup(null)}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{selectedGroup.backupType}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="font-mono">{selectedGroup.backupId}</span>
|
||||||
|
</h3>
|
||||||
|
{selectedGroup.comment && (
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedGroup.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmDeleteGroup(true)}
|
||||||
|
disabled={deleteGroup.isPending}
|
||||||
|
>
|
||||||
|
<Trash className="h-3.5 w-3.5" />
|
||||||
|
Delete Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{snapshotsLoading ? (
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !snapshots || snapshots.length === 0 ? (
|
||||||
|
<EmptyState icon={HardDrive} title="No snapshots in this group" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Backup Time</th>
|
||||||
|
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Size</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Protected</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Verification</th>
|
||||||
|
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr
|
||||||
|
key={snapshot.backupTime}
|
||||||
|
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatTimestamp(snapshot.backupTime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
|
||||||
|
{formatBytes(snapshot.size ?? 0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{snapshot.protected ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
Protected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{snapshot.verification?.state ? (
|
||||||
|
snapshot.verification.state === 'ok' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-success/25 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
|
||||||
|
<CheckCircle className="h-3 w-3" />
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title="Download files"
|
||||||
|
onClick={() => setDownloadSnapshot(snapshot)}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
title="Delete snapshot"
|
||||||
|
disabled={snapshot.protected}
|
||||||
|
onClick={() => setConfirmDeleteSnapshot(snapshot)}
|
||||||
|
>
|
||||||
|
<Trash className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">Backup Groups</h3>
|
||||||
|
<span className="text-sm text-muted-foreground">{groups?.length ?? 0} groups</span>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{!groups || groups.length === 0 ? (
|
||||||
|
<EmptyState icon={HardDrive} title="No backup groups found" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Backup ID</th>
|
||||||
|
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Backups</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Last Backup</th>
|
||||||
|
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Comment</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<tr
|
||||||
|
key={`${group.backupType}-${group.backupId}`}
|
||||||
|
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
|
||||||
|
onClick={() => setSelectedGroup(group)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{group.backupType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono">{group.backupId}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{group.backupCount ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatTimestamp(group.lastBackup)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{group.comment ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Job lists */}
|
||||||
|
<JobTable title="Verify Jobs" icon={ShieldCheck} jobs={verifyJobs} />
|
||||||
|
<JobTable title="Prune Jobs" icon={Eraser} jobs={pruneJobs} showKeep />
|
||||||
|
<JobTable title="GC Jobs" icon={Recycle} jobs={gcJobs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<VerifyDialog open={verifyOpen} onOpenChange={setVerifyOpen} store={store} />
|
||||||
|
<PruneDialog open={pruneOpen} onOpenChange={setPruneOpen} store={store} />
|
||||||
|
<GcDialog open={gcOpen} onOpenChange={setGcOpen} store={store} />
|
||||||
|
|
||||||
|
{downloadSnapshot && (
|
||||||
|
<DownloadFilesDialog
|
||||||
|
open={!!downloadSnapshot}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDownloadSnapshot(null)
|
||||||
|
}}
|
||||||
|
store={store}
|
||||||
|
backupId={downloadSnapshot.backupId}
|
||||||
|
backupType={downloadSnapshot.backupType}
|
||||||
|
backupTime={downloadSnapshot.backupTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDeleteSnapshot !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirmDeleteSnapshot(null)
|
||||||
|
}}
|
||||||
|
title="Delete Snapshot"
|
||||||
|
description={
|
||||||
|
confirmDeleteSnapshot
|
||||||
|
? `Are you sure you want to delete snapshot ${confirmDeleteSnapshot.backupType}/${confirmDeleteSnapshot.backupId}@${confirmDeleteSnapshot.backupTime}? This action cannot be undone.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
isLoading={deleteSnapshot.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirmDeleteSnapshot) {
|
||||||
|
deleteSnapshot.mutate(
|
||||||
|
{
|
||||||
|
store,
|
||||||
|
backupId: confirmDeleteSnapshot.backupId,
|
||||||
|
backupType: confirmDeleteSnapshot.backupType,
|
||||||
|
backupTime: confirmDeleteSnapshot.backupTime,
|
||||||
|
},
|
||||||
|
{ onSettled: () => setConfirmDeleteSnapshot(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDeleteGroup}
|
||||||
|
onOpenChange={setConfirmDeleteGroup}
|
||||||
|
title="Delete Backup Group"
|
||||||
|
description={
|
||||||
|
selectedGroup
|
||||||
|
? `Are you sure you want to delete the whole group ${selectedGroup.backupType}/${selectedGroup.backupId} including all snapshots? This action cannot be undone.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
isLoading={deleteGroup.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedGroup) {
|
||||||
|
deleteGroup.mutate(
|
||||||
|
{
|
||||||
|
store,
|
||||||
|
backupId: selectedGroup.backupId,
|
||||||
|
backupType: selectedGroup.backupType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setConfirmDeleteGroup(false)
|
||||||
|
setSelectedGroup(null)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { HardDrive, AlertTriangle, XCircle } from 'lucide-react'
|
||||||
|
import { usePbsDatastores } from '@/hooks/usePbs'
|
||||||
|
import { formatBytes } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { PbsDatastore } from '@/types/pbs'
|
||||||
|
|
||||||
|
interface PbsDatastoresProps {
|
||||||
|
connectionId: string
|
||||||
|
onDatastoreClick?: (store: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageColor(percent: number): string {
|
||||||
|
if (percent >= 90) return 'bg-destructive'
|
||||||
|
if (percent >= 70) return 'bg-warning'
|
||||||
|
return 'bg-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function PbsDatastoreCard({
|
||||||
|
datastore,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
datastore: PbsDatastore
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const percent = datastore.total && datastore.total > 0
|
||||||
|
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||||
|
: 0
|
||||||
|
const usageColor = getUsageColor(percent)
|
||||||
|
const hasError = !!datastore.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer transition-all duration-150 hover:-translate-y-0.5 hover:shadow-card',
|
||||||
|
hasError && 'border-destructive/40',
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="truncate font-mono">{datastore.store}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{datastore.backendType && (
|
||||||
|
<span className="uppercase bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{datastore.backendType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{datastore.mountStatus && (
|
||||||
|
<span className="font-mono">{datastore.mountStatus}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{datastore.comment && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{datastore.comment}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Usage</span>
|
||||||
|
<span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size Info */}
|
||||||
|
<div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>{formatBytes(datastore.used ?? 0)} used</span>
|
||||||
|
<span>{formatBytes(datastore.total ?? 0)} total</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatBytes(datastore.avail ?? 0)} available
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
{(hasError || datastore.maintenance) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
|
{hasError && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{datastore.maintenance && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
Maintenance
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PbsDatastores({ connectionId, onDatastoreClick }: PbsDatastoresProps) {
|
||||||
|
const { data: datastores, isLoading, error } = usePbsDatastores(connectionId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageSkeleton filter>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageSkeleton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-destructive">Failed to load datastores</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Datastores</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{datastores?.length ?? 0} datastores on this backup server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datastore Grid */}
|
||||||
|
{!datastores || datastores.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={HardDrive}
|
||||||
|
title="No datastores found"
|
||||||
|
description="Create a datastore on the backup server to get started"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{datastores.map((datastore) => (
|
||||||
|
<PbsDatastoreCard
|
||||||
|
key={datastore.store}
|
||||||
|
datastore={datastore}
|
||||||
|
onClick={() => onDatastoreClick?.(datastore.store)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { PageSkeleton } from '@/components/ui/skeleton'
|
||||||
|
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
|
||||||
|
import { AlertCircle, Cpu, Server, Clock, HardDrive, Database } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
usePbsVersion,
|
||||||
|
usePbsNodeStatus,
|
||||||
|
usePbsDatastores,
|
||||||
|
queryKeys,
|
||||||
|
} from '@/hooks/usePbs'
|
||||||
|
import { formatBytes, formatUptime } from '@/lib/format'
|
||||||
|
import type { PbsDatastore } from '@/types/pbs'
|
||||||
|
|
||||||
|
interface PbsOverviewProps {
|
||||||
|
connectionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageColor(percent: number): string {
|
||||||
|
if (percent >= 90) return 'bg-destructive'
|
||||||
|
if (percent >= 70) return 'bg-warning'
|
||||||
|
return 'bg-success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatastoreSummaryRow({ datastore }: { datastore: PbsDatastore }) {
|
||||||
|
const percent = datastore.total && datastore.total > 0
|
||||||
|
? ((datastore.used ?? 0) / datastore.total) * 100
|
||||||
|
: 0
|
||||||
|
const usageColor = getUsageColor(percent)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-mono text-sm font-medium">{datastore.store}</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex justify-between font-mono text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
<span>{formatBytes(datastore.used ?? 0)} used</span>
|
||||||
|
<span>{formatBytes(datastore.total ?? 0)} total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PbsOverview({ connectionId }: PbsOverviewProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: version, isLoading: versionLoading, error: versionError } = usePbsVersion(connectionId)
|
||||||
|
const { data: nodeStatus, isLoading: nodeStatusLoading, error: nodeStatusError } = usePbsNodeStatus(connectionId)
|
||||||
|
const { data: datastores, isLoading: datastoresLoading, error: datastoresError } = usePbsDatastores(connectionId)
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.pbsVersion(connectionId) })
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.pbsNodeStatus(connectionId) })
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.pbsDatastores(connectionId) })
|
||||||
|
}, [queryClient, connectionId])
|
||||||
|
|
||||||
|
if (versionLoading || nodeStatusLoading || datastoresLoading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasError = versionError || nodeStatusError || datastoresError
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
|
||||||
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">Unable to load server overview</h3>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{versionError?.message || nodeStatusError?.message || datastoresError?.message || 'An error occurred while loading data'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||||
|
Check the connection to your Proxmox Backup Server and try again.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={handleRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpus = nodeStatus?.cpuinfo?.cpus ?? 1
|
||||||
|
const usedCpu = (nodeStatus?.cpu ?? 0) * cpus
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
|
||||||
|
Overview
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Proxmox Backup Server status and resource usage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Version</CardTitle>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||||
|
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||||
|
{version?.version ?? '—'}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
release {version?.release ?? '—'} · repoid {version?.repoid ?? '—'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Uptime</CardTitle>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||||
|
{formatUptime(nodeStatus?.uptime ?? 0)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{nodeStatus?.currentKernel?.release ?? '—'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Load</CardTitle>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||||
|
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||||
|
{nodeStatus?.loadavg?.[0]?.toFixed(2) ?? '—'}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 truncate font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{nodeStatus?.cpuinfo?.model ?? '—'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Gauges */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<ResourceGauge
|
||||||
|
label="CPU"
|
||||||
|
used={usedCpu}
|
||||||
|
total={cpus}
|
||||||
|
icon="cpu"
|
||||||
|
formatValue={(v) => `${v.toFixed(1)} cores`}
|
||||||
|
/>
|
||||||
|
<ResourceGauge
|
||||||
|
label="Memory"
|
||||||
|
used={nodeStatus?.memory?.used ?? 0}
|
||||||
|
total={nodeStatus?.memory?.total ?? 0}
|
||||||
|
icon="memory"
|
||||||
|
formatValue={formatBytes}
|
||||||
|
/>
|
||||||
|
<ResourceGauge
|
||||||
|
label="Root Storage"
|
||||||
|
used={nodeStatus?.root?.used ?? 0}
|
||||||
|
total={nodeStatus?.root?.total ?? 0}
|
||||||
|
icon="disk"
|
||||||
|
formatValue={formatBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datastore Usage Summary */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Datastores</CardTitle>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||||
|
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{!datastores || datastores.length === 0 ? (
|
||||||
|
<p className="col-span-full text-sm text-muted-foreground">No datastores configured.</p>
|
||||||
|
) : (
|
||||||
|
datastores.map((datastore) => (
|
||||||
|
<DatastoreSummaryRow key={datastore.store} datastore={datastore} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Download, File, Lock, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
import { useConnectionStore } from '@/stores/connectionStore'
|
||||||
|
import { usePbsSnapshotFiles, usePbsDownloadFile } from '@/hooks/usePbs'
|
||||||
|
import { formatBytes } from '@/lib/format'
|
||||||
|
import type { PbsSnapshot } from '@/types/pbs'
|
||||||
|
|
||||||
|
interface DownloadFilesDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
store: string
|
||||||
|
backupId: string
|
||||||
|
backupType: PbsSnapshot['backupType']
|
||||||
|
backupTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadFilesDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
}: DownloadFilesDialogProps) {
|
||||||
|
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||||
|
const {
|
||||||
|
data: files,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = usePbsSnapshotFiles(
|
||||||
|
open ? activeConnectionId : null,
|
||||||
|
open ? store : null,
|
||||||
|
open ? backupId : null,
|
||||||
|
open ? backupType : null,
|
||||||
|
open ? backupTime : null,
|
||||||
|
)
|
||||||
|
const downloadFile = usePbsDownloadFile()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Download Files</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Files in snapshot {backupType}/{backupId}@{backupTime} on datastore "{store}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-72">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2 p-1">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
Failed to load snapshot files
|
||||||
|
</div>
|
||||||
|
) : !files || files.length === 0 ? (
|
||||||
|
<EmptyState icon={File} title="No files in this snapshot" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.filename}
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-border/70 bg-muted/30 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-sm">{file.filename}</span>
|
||||||
|
{file.cryptMode && file.cryptMode !== 'none' && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-sm border border-warning/25 bg-warning/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-warning">
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
{file.cryptMode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatBytes(file.size ?? 0)}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={downloadFile.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
downloadFile.mutate({
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
fileName: file.filename,
|
||||||
|
decoded: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Raw
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={downloadFile.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
downloadFile.mutate({
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
fileName: file.filename,
|
||||||
|
decoded: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{downloadFile.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
Decoded
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={downloadFile.isPending}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { usePbsRunGc } from '@/hooks/usePbs'
|
||||||
|
|
||||||
|
interface GcDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
store: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GcDialog({ open, onOpenChange, store }: GcDialogProps) {
|
||||||
|
const runGc = usePbsRunGc()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Garbage Collection</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Run garbage collection on datastore "{store}"? This removes chunks that are no
|
||||||
|
longer referenced by any backup.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={runGc.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => runGc.mutate({ store }, { onSuccess: () => onOpenChange(false) })}
|
||||||
|
disabled={runGc.isPending}
|
||||||
|
>
|
||||||
|
{runGc.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Run Garbage Collection
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { usePbsRunPrune } from '@/hooks/usePbs'
|
||||||
|
|
||||||
|
interface PruneDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
store: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalInt(value: string): number | undefined {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const n = Number(trimmed)
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepFields: { key: string; label: string; placeholder: string }[] = [
|
||||||
|
{ key: 'keepLast', label: 'Keep last', placeholder: 'e.g. 7' },
|
||||||
|
{ key: 'keepDaily', label: 'Keep daily', placeholder: 'e.g. 14' },
|
||||||
|
{ key: 'keepWeekly', label: 'Keep weekly', placeholder: 'e.g. 8' },
|
||||||
|
{ key: 'keepMonthly', label: 'Keep monthly', placeholder: 'e.g. 6' },
|
||||||
|
{ key: 'keepYearly', label: 'Keep yearly', placeholder: 'e.g. 2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PruneDialog({ open, onOpenChange, store }: PruneDialogProps) {
|
||||||
|
const [keepValues, setKeepValues] = useState<Record<string, string>>({})
|
||||||
|
const [dryRun, setDryRun] = useState(true)
|
||||||
|
const runPrune = usePbsRunPrune()
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
runPrune.mutate(
|
||||||
|
{
|
||||||
|
store,
|
||||||
|
keepLast: parseOptionalInt(keepValues.keepLast ?? ''),
|
||||||
|
keepDaily: parseOptionalInt(keepValues.keepDaily ?? ''),
|
||||||
|
keepWeekly: parseOptionalInt(keepValues.keepWeekly ?? ''),
|
||||||
|
keepMonthly: parseOptionalInt(keepValues.keepMonthly ?? ''),
|
||||||
|
keepYearly: parseOptionalInt(keepValues.keepYearly ?? ''),
|
||||||
|
dryRun,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => onOpenChange(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Prune Datastore</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Prune backup groups on datastore "{store}" according to the retention rules below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{keepFields.map((field) => (
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
<Label htmlFor={`prune-${field.key}`}>{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
id={`prune-${field.key}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={keepValues[field.key] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setKeepValues((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="prune-dry-run"
|
||||||
|
checked={dryRun}
|
||||||
|
onChange={(e) => setDryRun(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="prune-dry-run" className="text-sm font-normal cursor-pointer">
|
||||||
|
Dry run (do not actually remove anything)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={runPrune.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={runPrune.isPending}>
|
||||||
|
{runPrune.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Run Prune
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { usePbsRunVerify } from '@/hooks/usePbs'
|
||||||
|
|
||||||
|
interface VerifyDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
store: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerifyDialog({ open, onOpenChange, store }: VerifyDialogProps) {
|
||||||
|
const runVerify = usePbsRunVerify()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Verify Datastore</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Run verification on datastore "{store}"? Already-verified snapshots will be skipped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={runVerify.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
runVerify.mutate({ store }, { onSuccess: () => onOpenChange(false) })
|
||||||
|
}
|
||||||
|
disabled={runVerify.isPending}
|
||||||
|
>
|
||||||
|
{runVerify.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Run Verify
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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 { PbsSnapshot } from '@/types/pbs'
|
||||||
|
|
||||||
|
// Query keys
|
||||||
|
export const queryKeys = {
|
||||||
|
pbsDatastores: (id: string) => ['pbsDatastores', id],
|
||||||
|
pbsVersion: (id: string) => ['pbsVersion', id],
|
||||||
|
pbsNodeStatus: (id: string) => ['pbsNodeStatus', id],
|
||||||
|
pbsGroups: (id: string, store: string) => ['pbsGroups', id, store],
|
||||||
|
pbsSnapshots: (id: string, store: string, backupId: string, backupType: string) => [
|
||||||
|
'pbsSnapshots',
|
||||||
|
id,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
],
|
||||||
|
pbsSnapshotFiles: (id: string, store: string, backupId: string, backupType: string, backupTime: number) => [
|
||||||
|
'pbsSnapshotFiles',
|
||||||
|
id,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
],
|
||||||
|
pbsVerifyJobs: (id: string) => ['pbsVerifyJobs', id],
|
||||||
|
pbsPruneJobs: (id: string) => ['pbsPruneJobs', id],
|
||||||
|
pbsGcJobs: (id: string) => ['pbsGcJobs', id],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
export const usePbsDatastores = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsDatastores(connectionId!),
|
||||||
|
queryFn: () => api.getPbsDatastores(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
refetchInterval: 30000, // 30 seconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsVersion = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsVersion(connectionId!),
|
||||||
|
queryFn: () => api.getPbsVersion(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
refetchInterval: 30000, // 30 seconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsNodeStatus = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsNodeStatus(connectionId!),
|
||||||
|
queryFn: () => api.getPbsNodeStatus(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsGroups = (connectionId: string | null, store: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsGroups(connectionId!, store!),
|
||||||
|
queryFn: () => api.getPbsGroups(connectionId!, store!),
|
||||||
|
enabled: !!connectionId && !!store,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsSnapshots = (
|
||||||
|
connectionId: string | null,
|
||||||
|
store: string | null,
|
||||||
|
backupId: string | null,
|
||||||
|
backupType: PbsSnapshot['backupType'] | null,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsSnapshots(connectionId!, store!, backupId!, backupType!),
|
||||||
|
queryFn: () => api.getPbsSnapshots(connectionId!, store!, backupId!, backupType!),
|
||||||
|
enabled: !!connectionId && !!store && !!backupId && !!backupType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsSnapshotFiles = (
|
||||||
|
connectionId: string | null,
|
||||||
|
store: string | null,
|
||||||
|
backupId: string | null,
|
||||||
|
backupType: PbsSnapshot['backupType'] | null,
|
||||||
|
backupTime: number | null,
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!),
|
||||||
|
queryFn: () => api.getPbsSnapshotFiles(connectionId!, store!, backupId!, backupType!, backupTime!),
|
||||||
|
enabled: !!connectionId && !!store && !!backupId && !!backupType && !!backupTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsVerifyJobs = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsVerifyJobs(connectionId!),
|
||||||
|
queryFn: () => api.getPbsVerifyJobs(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsPruneJobs = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsPruneJobs(connectionId!),
|
||||||
|
queryFn: () => api.getPbsPruneJobs(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsGcJobs = (connectionId: string | null) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.pbsGcJobs(connectionId!),
|
||||||
|
queryFn: () => api.getPbsGcJobs(connectionId!),
|
||||||
|
enabled: !!connectionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
export const usePbsDeleteSnapshot = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
}: {
|
||||||
|
store: string
|
||||||
|
backupId: string
|
||||||
|
backupType: PbsSnapshot['backupType']
|
||||||
|
backupTime: number
|
||||||
|
}) => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
return api.deletePbsSnapshot(connId, store, backupId, backupType, backupTime)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
addToast('Snapshot deleted', 'success')
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId
|
||||||
|
if (connId) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.pbsSnapshots(connId, variables.store, variables.backupId, variables.backupType),
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to delete snapshot', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsDeleteGroup = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
}: {
|
||||||
|
store: string
|
||||||
|
backupId: string
|
||||||
|
backupType: PbsSnapshot['backupType']
|
||||||
|
}) => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
return api.deletePbsGroup(connId, store, backupId, backupType)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
addToast('Backup group deleted', 'success')
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId
|
||||||
|
if (connId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGroups(connId, variables.store) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to delete backup group', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsRunVerify = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ store }: { store: string }) => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
return api.runPbsVerify(connId, store)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
addToast('Verification started', 'success')
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId
|
||||||
|
if (connId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsVerifyJobs(connId) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to start verification', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsRunPrune = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
store,
|
||||||
|
keepLast,
|
||||||
|
keepDaily,
|
||||||
|
keepWeekly,
|
||||||
|
keepMonthly,
|
||||||
|
keepYearly,
|
||||||
|
dryRun,
|
||||||
|
}: {
|
||||||
|
store: string
|
||||||
|
keepLast?: number
|
||||||
|
keepDaily?: number
|
||||||
|
keepWeekly?: number
|
||||||
|
keepMonthly?: number
|
||||||
|
keepYearly?: number
|
||||||
|
dryRun?: boolean
|
||||||
|
}) => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
return api.runPbsPrune(
|
||||||
|
connId,
|
||||||
|
store,
|
||||||
|
keepLast,
|
||||||
|
keepDaily,
|
||||||
|
keepWeekly,
|
||||||
|
keepMonthly,
|
||||||
|
keepYearly,
|
||||||
|
dryRun ?? false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
addToast('Prune started', 'success')
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId
|
||||||
|
if (connId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsPruneJobs(connId) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to start prune', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsRunGc = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ store }: { store: string }) => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
return api.runPbsGc(connId, store)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
addToast('Garbage collection started', 'success')
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId
|
||||||
|
if (connId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsDatastores(connId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.pbsGcJobs(connId) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to start garbage collection', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePbsDownloadFile = () => {
|
||||||
|
const { addToast } = useToast()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
fileName,
|
||||||
|
decoded,
|
||||||
|
}: {
|
||||||
|
store: string
|
||||||
|
backupId: string
|
||||||
|
backupType: PbsSnapshot['backupType']
|
||||||
|
backupTime: number
|
||||||
|
fileName: string
|
||||||
|
decoded: boolean
|
||||||
|
}): Promise<string | null> => {
|
||||||
|
const connId = useConnectionStore.getState().activeConnectionId!
|
||||||
|
// In browser mock mode there is no save dialog, so default the target
|
||||||
|
// path to the file name. In Tauri mode the native save dialog picks it.
|
||||||
|
let savePath = fileName
|
||||||
|
if (api.isTauri()) {
|
||||||
|
const { save } = await import('@tauri-apps/plugin-dialog')
|
||||||
|
const chosen = await save({ defaultPath: fileName })
|
||||||
|
if (chosen === null) return null // dialog cancelled — skip the download
|
||||||
|
savePath = chosen
|
||||||
|
}
|
||||||
|
return api.downloadPbsSnapshotFile(
|
||||||
|
connId,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
fileName,
|
||||||
|
decoded,
|
||||||
|
savePath,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (savePath, variables) => {
|
||||||
|
if (savePath === null) return // dialog cancelled — no toast
|
||||||
|
addToast(`Downloaded ${variables.fileName}`, 'success')
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
addToast(error.message || 'Failed to download file', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+290
-1
@@ -8,7 +8,17 @@ import type {
|
|||||||
LoadConnectionsResult,
|
LoadConnectionsResult,
|
||||||
ConnectResult,
|
ConnectResult,
|
||||||
ConnectionStatusInfo,
|
ConnectionStatusInfo,
|
||||||
|
ServerType,
|
||||||
} from '@/types/connection'
|
} from '@/types/connection'
|
||||||
|
import type {
|
||||||
|
PbsDatastore,
|
||||||
|
PbsVersion,
|
||||||
|
PbsNodeStatus,
|
||||||
|
PbsBackupGroup,
|
||||||
|
PbsSnapshot,
|
||||||
|
PbsSnapshotFile,
|
||||||
|
PbsJob,
|
||||||
|
} from '@/types/pbs'
|
||||||
import type {
|
import type {
|
||||||
ProxmoxNode,
|
ProxmoxNode,
|
||||||
ProxmoxVM,
|
ProxmoxVM,
|
||||||
@@ -137,6 +147,7 @@ export const loginWithPassword = async (
|
|||||||
export const loginWithToken = async (
|
export const loginWithToken = async (
|
||||||
url: string,
|
url: string,
|
||||||
token: string,
|
token: string,
|
||||||
|
serverType: ServerType = 'pve',
|
||||||
): Promise<LoginResult> => {
|
): Promise<LoginResult> => {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
return mockResponse({
|
return mockResponse({
|
||||||
@@ -145,7 +156,7 @@ export const loginWithToken = async (
|
|||||||
csrfToken: '',
|
csrfToken: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return invokeCommand<LoginResult>('login_with_token', { url, token })
|
return invokeCommand<LoginResult>('login_with_token', { url, token, serverType })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logout = async (connectionId: string): Promise<void> => {
|
export const logout = async (connectionId: string): Promise<void> => {
|
||||||
@@ -592,3 +603,281 @@ export const updateTrayMenu = async (
|
|||||||
if (!isTauri()) return
|
if (!isTauri()) return
|
||||||
return invokeCommand<void>('update_tray_menu', { connections })
|
return invokeCommand<void>('update_tray_menu', { connections })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proxmox Backup Server (PBS)
|
||||||
|
export const getPbsDatastores = async (connectionId: string): Promise<PbsDatastore[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
store: 'backup-store',
|
||||||
|
comment: 'Main backup store',
|
||||||
|
backendType: 'filesystem',
|
||||||
|
mountStatus: 'mounted',
|
||||||
|
total: 2_000_000_000_000,
|
||||||
|
used: 800_000_000_000,
|
||||||
|
avail: 1_200_000_000_000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsDatastore[]>('get_pbs_datastores', { connectionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsVersion = async (connectionId: string): Promise<PbsVersion> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse({ version: '3.2.3', release: 'bookworm', repoid: 'dd6b00e2' })
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsVersion>('get_pbs_version', { connectionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsNodeStatus = async (connectionId: string): Promise<PbsNodeStatus> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse({
|
||||||
|
cpu: 0.15,
|
||||||
|
loadavg: [0.42, 0.38, 0.31],
|
||||||
|
uptime: 5 * 86400,
|
||||||
|
memory: { free: 12_000_000_000, total: 32_000_000_000, used: 20_000_000_000 },
|
||||||
|
root: { avail: 1_200_000_000_000, total: 2_000_000_000_000, used: 800_000_000_000 },
|
||||||
|
swap: { free: 4_000_000_000, total: 4_000_000_000, used: 0 },
|
||||||
|
cpuinfo: { cpus: 8, model: 'AMD Ryzen 7 5700G', sockets: 1 },
|
||||||
|
currentKernel: {
|
||||||
|
machine: 'x86_64',
|
||||||
|
release: '6.8.12-4-pve',
|
||||||
|
sysname: 'Linux',
|
||||||
|
version: '#1 SMP PREEMPT_DYNAMIC',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsNodeStatus>('get_pbs_node_status', { connectionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsGroups = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
): Promise<PbsBackupGroup[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
backupId: '100',
|
||||||
|
backupType: 'vm',
|
||||||
|
backupCount: 3,
|
||||||
|
lastBackup: 1_700_000_000,
|
||||||
|
comment: 'Web server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backupId: '200',
|
||||||
|
backupType: 'ct',
|
||||||
|
backupCount: 2,
|
||||||
|
lastBackup: 1_690_000_000,
|
||||||
|
comment: 'Container host',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsBackupGroup[]>('get_pbs_groups', { connectionId, store })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsSnapshots = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
backupId: string,
|
||||||
|
backupType: PbsSnapshot['backupType'],
|
||||||
|
): Promise<PbsSnapshot[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime: 1_700_000_000,
|
||||||
|
size: 1_500_000_000,
|
||||||
|
protected: true,
|
||||||
|
comment: 'Full backup',
|
||||||
|
verification: { state: 'ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime: 1_700_086_400,
|
||||||
|
size: 900_000_000,
|
||||||
|
comment: 'Incremental backup',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsSnapshot[]>('get_pbs_snapshots', { connectionId, store, backupId, backupType })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsSnapshotFiles = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
backupId: string,
|
||||||
|
backupType: PbsSnapshot['backupType'],
|
||||||
|
backupTime: number,
|
||||||
|
): Promise<PbsSnapshotFile[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{ filename: 'client.conf', size: 1024 },
|
||||||
|
{ filename: 'drive-scsi0.img.fidx', size: 64 * 1024 * 1024 * 1024, cryptMode: 'none' },
|
||||||
|
{ filename: 'index.json.blob', size: 2048 },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsSnapshotFile[]>('get_pbs_snapshot_files', {
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadPbsSnapshotFile = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
backupId: string,
|
||||||
|
backupType: PbsSnapshot['backupType'],
|
||||||
|
backupTime: number,
|
||||||
|
fileName: string,
|
||||||
|
decoded: boolean,
|
||||||
|
savePath: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!isTauri()) return mockResponse(savePath)
|
||||||
|
return invokeCommand<string>('download_pbs_snapshot_file', {
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
fileName,
|
||||||
|
decoded,
|
||||||
|
savePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deletePbsSnapshot = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
backupId: string,
|
||||||
|
backupType: PbsSnapshot['backupType'],
|
||||||
|
backupTime: number,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!isTauri()) return mockResponse(undefined)
|
||||||
|
return invokeCommand<void>('delete_pbs_snapshot', {
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
backupId,
|
||||||
|
backupType,
|
||||||
|
backupTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deletePbsGroup = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
backupId: string,
|
||||||
|
backupType: PbsSnapshot['backupType'],
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!isTauri()) return mockResponse(undefined)
|
||||||
|
return invokeCommand<void>('delete_pbs_group', { connectionId, store, backupId, backupType })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPbsVerify = async (connectionId: string, store: string): Promise<string> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse(`UPID:mock:00000000:00000000:00000000:verify:${store}::`)
|
||||||
|
}
|
||||||
|
return invokeCommand<string>('run_pbs_verify', { connectionId, store })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPbsPrune = async (
|
||||||
|
connectionId: string,
|
||||||
|
store: string,
|
||||||
|
keepLast?: number,
|
||||||
|
keepDaily?: number,
|
||||||
|
keepWeekly?: number,
|
||||||
|
keepMonthly?: number,
|
||||||
|
keepYearly?: number,
|
||||||
|
dryRun?: boolean,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse(`UPID:mock:00000000:00000000:00000000:prune:${store}::`)
|
||||||
|
}
|
||||||
|
return invokeCommand<string>('run_pbs_prune', {
|
||||||
|
connectionId,
|
||||||
|
store,
|
||||||
|
keepLast,
|
||||||
|
keepDaily,
|
||||||
|
keepWeekly,
|
||||||
|
keepMonthly,
|
||||||
|
keepYearly,
|
||||||
|
dryRun,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPbsGc = async (connectionId: string, store: string): Promise<string> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse(`UPID:mock:00000000:00000000:00000000:gc:${store}::`)
|
||||||
|
}
|
||||||
|
return invokeCommand<string>('run_pbs_gc', { connectionId, store })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsVerifyJobs = async (
|
||||||
|
connectionId: string,
|
||||||
|
store?: string,
|
||||||
|
): Promise<PbsJob[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
id: 'verify-1',
|
||||||
|
store: 'backup-store',
|
||||||
|
schedule: 'sun 01:00',
|
||||||
|
comment: 'Weekly verification',
|
||||||
|
lastRunState: 'OK',
|
||||||
|
lastRunEndtime: 1_700_000_000,
|
||||||
|
nextRun: 1_700_600_000,
|
||||||
|
maxDepth: 5,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsJob[]>('get_pbs_verify_jobs', { connectionId, store })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsPruneJobs = async (
|
||||||
|
connectionId: string,
|
||||||
|
store?: string,
|
||||||
|
): Promise<PbsJob[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
id: 'prune-1',
|
||||||
|
store: 'backup-store',
|
||||||
|
schedule: 'sat 02:00',
|
||||||
|
comment: 'Weekly pruning',
|
||||||
|
lastRunState: 'OK',
|
||||||
|
lastRunEndtime: 1_690_000_000,
|
||||||
|
nextRun: 1_700_000_000,
|
||||||
|
keepLast: 7,
|
||||||
|
keepDaily: 14,
|
||||||
|
keepWeekly: 8,
|
||||||
|
keepMonthly: 6,
|
||||||
|
keepYearly: 2,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsJob[]>('get_pbs_prune_jobs', { connectionId, store })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPbsGcJobs = async (
|
||||||
|
connectionId: string,
|
||||||
|
store?: string,
|
||||||
|
): Promise<PbsJob[]> => {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return mockResponse([
|
||||||
|
{
|
||||||
|
id: 'gc-1',
|
||||||
|
store: 'backup-store',
|
||||||
|
schedule: 'mon 03:00',
|
||||||
|
comment: 'Weekly garbage collection',
|
||||||
|
lastRunState: 'OK',
|
||||||
|
lastRunEndtime: 1_690_000_000,
|
||||||
|
nextRun: 1_700_000_000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return invokeCommand<PbsJob[]>('get_pbs_gc_jobs', { connectionId, store })
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
export type AuthMode = 'password' | 'token'
|
export type AuthMode = 'password' | 'token'
|
||||||
|
|
||||||
|
/** The kind of server a connection targets. Absent on persisted old
|
||||||
|
* connections, where it is treated as 'pve'. */
|
||||||
|
export type ServerType = 'pve' | 'pbs'
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -16,6 +20,8 @@ export interface ConnectionConfig {
|
|||||||
isCluster: boolean
|
isCluster: boolean
|
||||||
authMode: AuthMode
|
authMode: AuthMode
|
||||||
username?: string
|
username?: string
|
||||||
|
/** The kind of server this connection targets ('pve' when absent). */
|
||||||
|
serverType?: ServerType
|
||||||
/** Nodes discovered in the cluster this connection is anchored on. */
|
/** Nodes discovered in the cluster this connection is anchored on. */
|
||||||
nodes?: DiscoveredNode[]
|
nodes?: DiscoveredNode[]
|
||||||
/** The endpoint currently serving this connection (after failover). */
|
/** The endpoint currently serving this connection (after failover). */
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Proxmox Backup Server (PBS) API types
|
||||||
|
//
|
||||||
|
// PBS exposes kebab-case JSON fields; the Rust backend maps them to camelCase
|
||||||
|
// structs (serde `rename_all = "camelCase"`), so these types mirror the
|
||||||
|
// backend's shapes. All fields are optional unless marked required.
|
||||||
|
|
||||||
|
export interface PbsDatastore {
|
||||||
|
store: string // required
|
||||||
|
comment?: string
|
||||||
|
backendType?: string // 'filesystem' | 's3'
|
||||||
|
mountStatus?: string // 'mounted' | 'notmounted' | 'nonremovable'
|
||||||
|
maintenance?: string
|
||||||
|
total?: number
|
||||||
|
used?: number
|
||||||
|
avail?: number
|
||||||
|
error?: string
|
||||||
|
estimatedFullDate?: number
|
||||||
|
history?: number[]
|
||||||
|
gcStatus?: {
|
||||||
|
diskBytes?: number
|
||||||
|
diskChunks?: number
|
||||||
|
indexDataBytes?: number
|
||||||
|
indexFileCount?: number
|
||||||
|
pendingBytes?: number
|
||||||
|
pendingChunks?: number
|
||||||
|
removedBad?: number
|
||||||
|
removedBytes?: number
|
||||||
|
removedChunks?: number
|
||||||
|
stillBad?: number
|
||||||
|
cacheHits?: number
|
||||||
|
cacheMisses?: number
|
||||||
|
upid?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsVersion {
|
||||||
|
version: string
|
||||||
|
release: string
|
||||||
|
repoid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsNodeStatus {
|
||||||
|
cpu?: number
|
||||||
|
loadavg?: number[]
|
||||||
|
uptime?: number
|
||||||
|
memory?: { free?: number; total?: number; used?: number }
|
||||||
|
root?: { avail?: number; total?: number; used?: number }
|
||||||
|
swap?: { free?: number; total?: number; used?: number }
|
||||||
|
cpuinfo?: { cpus?: number; model?: string; sockets?: number }
|
||||||
|
currentKernel?: { machine?: string; release?: string; sysname?: string; version?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsBackupGroup {
|
||||||
|
backupId: string // required, e.g. "100"
|
||||||
|
backupType: 'vm' | 'ct' | 'host'
|
||||||
|
backupCount?: number
|
||||||
|
lastBackup?: number // unix epoch
|
||||||
|
comment?: string
|
||||||
|
files?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsSnapshot {
|
||||||
|
backupId: string // required
|
||||||
|
backupType: 'vm' | 'ct' | 'host'
|
||||||
|
backupTime: number // required, unix epoch
|
||||||
|
size?: number
|
||||||
|
protected?: boolean
|
||||||
|
comment?: string
|
||||||
|
files?: string[]
|
||||||
|
fingerprint?: string
|
||||||
|
owner?: string
|
||||||
|
verification?: { state?: 'ok' | 'failed'; upid?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsSnapshotFile {
|
||||||
|
filename: string
|
||||||
|
size?: number
|
||||||
|
cryptMode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PbsJob {
|
||||||
|
id: string // required
|
||||||
|
store?: string
|
||||||
|
schedule?: string
|
||||||
|
comment?: string
|
||||||
|
disable?: boolean
|
||||||
|
lastRunState?: string
|
||||||
|
lastRunEndtime?: number
|
||||||
|
nextRun?: number
|
||||||
|
keepLast?: number
|
||||||
|
keepDaily?: number
|
||||||
|
keepWeekly?: number
|
||||||
|
keepMonthly?: number
|
||||||
|
keepYearly?: number
|
||||||
|
ignoreVerified?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user