feat: add cluster discovery, failover, VM management, and console support

This commit is contained in:
Matt
2026-08-09 23:01:51 +00:00
parent 7bb3ba48f0
commit 190fdb2573
100 changed files with 11351 additions and 1604 deletions
+222 -155
View File
@@ -1,53 +1,128 @@
# ProxmoxDesktop # ProxmoxDesktop
A cross-platform desktop application for managing Proxmox VE servers and clusters. Built with Tauri, React, and TypeScript. 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.
## Features ## Features
### Phase 1: Foundation (Current) ### Connections and authentication
- ✅ Multi-server connection management
- ✅ API token authentication
- ✅ Self-signed certificate handling (TOFU)
- ✅ OS keyring integration for secure credential storage
- ✅ Dashboard with cluster overview
- ✅ Node status monitoring
- ✅ Real-time resource usage tracking
### Planned Features - Manage multiple simultaneous connections to servers and clusters.
- VM & Container lifecycle management (start, stop, reboot, shutdown) - Authenticate with an API token or with a username and password. Password mode uses ticket-based auth (PVEAuthCookie plus CSRF token).
- Console access (noVNC for VMs, xterm.js for containers) - Credentials live in the OS keyring. API tokens never touch disk.
- Disk management (add, resize, remove, move) - Connections persist to `{app-config-dir}/proxmoxdesktop/connections.json`. The last active connection reconnects on launch.
- Network interface management
- Backup job management and restore ### Cluster support
- Snapshot management
- System tray integration - On connect, the backend queries `/nodes` and `/cluster/status` and derives an endpoint URL for every cluster node, keeping the connection's scheme and port (default 8006) with the node's cluster IP (or name) as the host. The connected node is marked primary and the node list is stored with the connection.
- Command palette (Cmd/Ctrl+K) - API requests route through an ordered, deduplicated endpoint list: the primary endpoint, configured fallbacks, then the discovered nodes. A connection refused, timeout, DNS, or transport failure retries the request on the next endpoint. Status is `connected` when the primary serves, `failover` while a fallback serves, and `failed` when every endpoint is unreachable.
- Automatic failover with primary/fallback endpoints - The frontend polls connection status every 10 seconds. The sidebar lists the cluster nodes with a live status dot per node (primary marked), shows a "Failover: {url}" note with an amber banner while on a fallback, and "Offline" when disconnected or failed.
- Connecting to another node of the same cluster (matched by cluster id) merges instead of duplicating: the new node's URL joins the existing connection as a fallback endpoint, the duplicate connection is removed, the active connection switches, and a toast explains what happened.
### Certificate handling (TOFU)
- On first connect, the app captures the server's TLS certificate and shows its SHA-256 fingerprint for confirmation.
- The fingerprint is pinned and verified on every subsequent connect. A per-connection "accept anyway" option covers cases where you knowingly replaced the server certificate.
### Dashboard and monitoring
- Cluster overview with a node health grid, resource gauges, an activity feed, and quick actions.
- Per-node view: resource usage, system information, and the VMs and containers on the node.
### VMs and containers (QEMU and LXC)
- List and filter VMs and containers.
- Lifecycle actions: start, stop, shutdown, reboot, suspend, resume, migrate.
- VM detail with overview, hardware, disks, network, snapshots, and console tabs.
### Storage and networking
- Disk management: list, add, resize, remove, move.
- Network interface management: list, add, edit, remove.
- Storage overview and per-storage detail.
### Snapshots and backups
- Snapshot list, create, delete, rollback.
- Backup jobs: list, create, edit, delete, run now. Restore or delete existing backups.
### Console
- noVNC for VMs and an xterm.js terminal for containers, with fullscreen and Ctrl+Alt+Del support.
### Tasks and events
- Task list with status filtering and a running-task indicator.
- A WebSocket relay in the backend forwards task, node, and VM events to the frontend. Polling remains the fallback.
### Interface
- Command palette (Ctrl/Cmd+K).
- System tray with a quick menu: show/hide, connection list, quit.
- Dark-first design with Geist for UI text, JetBrains Mono for data, and a single muted-orange accent. Light, dark, and system themes.
## Tech Stack ## Tech Stack
### Frontend ### Frontend
- **React 19** - UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool and dev server
- **Tailwind CSS** - Utility-first styling
- **shadcn/ui** - Component library (Radix UI + Tailwind)
- **TanStack Query** - Server state management
- **Zustand** - Client state management
- **Lucide React** - Icons
### Backend (Tauri) - React 19, TypeScript
- **Tauri 2** - Desktop app framework - Vite, Tailwind CSS v4
- **Rust** - Backend logic - Radix UI-based components (shadcn-style)
- **reqwest** - HTTP client with TLS support - TanStack Query (server state), Zustand (client state)
- **keyring** - OS keyring integration - lucide-react icons, Recharts gauges, noVNC, xterm.js
- **tokio** - Async runtime
### Backend
- Tauri 2
- reqwest with rustls for HTTP, keyring for the OS keyring, tokio for async
- rustls, x509-cert, and sha2 for certificate capture and fingerprinting
- tokio-tungstenite for the WebSocket relay
### Development and testing
- httpmock, rcgen, and tempfile for Rust tests
- oxlint for frontend linting
## Architecture
### Frontend to backend
The React frontend calls Rust commands over Tauri IPC. The backend proxies them to the Proxmox API over HTTPS.
```
React component
→ Tauri invoke
→ Rust command
→ Proxmox API ({base}/api2/json{path})
```
### API core
A Rust `ConnectionManager` owns one HTTP client per connection. A shared `api_request` core builds the `{base}/api2/json{path}` URL, injects auth (`PVEAPIToken` header for token mode; `PVEAuthCookie` and `CSRFPreventionToken` for password mode), unwraps the `{data}` envelope, and maps errors to the frontend.
The same core routes each request through an ordered, deduplicated endpoint list: the primary endpoint, configured fallbacks, then the discovered cluster nodes. On a connect, timeout, DNS, or transport failure it retries on the next endpoint, and the connection's runtime status tracks whether the primary (`connected`), a fallback (`failover`), or no endpoint (`failed`) served the last request.
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.
### 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.
Fingerprint pinning runs at connect time against the node you connected to. Fallback and discovered endpoints are used only in degraded failover mode and get no pin check of their own; the transport already accepts their self-signed certificates. This is a deliberate trade-off to keep failover automatic in home-lab setups.
### Real-time events
A WebSocket manager connects to the Proxmox event API and relays task, node, and VM events as Tauri events. The frontend falls back to polling when the socket is not connected.
### State management
TanStack Query handles server state (fetching, caching, refetching). Zustand handles client state (active connection, UI, preferences).
## Prerequisites ## Prerequisites
### System Dependencies ### System dependencies
#### Linux (Ubuntu/Debian) #### Linux (Ubuntu/Debian)
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y \ sudo apt install -y \
@@ -64,35 +139,30 @@ sudo apt install -y \
``` ```
#### macOS #### macOS
```bash
# Install Xcode Command Line Tools
xcode-select --install
# Install Homebrew dependencies ```bash
xcode-select --install
brew install rust brew install rust
``` ```
#### Windows #### Windows
```powershell
# Install Visual Studio Build Tools
# Download from: https://visualstudio.microsoft.com/visual-cpp-build-tools/
# Select "Desktop development with C++"
# Install Rust via rustup - Install Visual Studio Build Tools and select "Desktop development with C++".
winget install Rustlang.Rustup - Install Rust via rustup: `winget install Rustlang.Rustup`
```
### Node.js ### Node.js
Node.js 20 or newer (nvm recommended):
```bash ```bash
# Install Node.js 20+ via nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install 20 nvm install 20
nvm use 20 nvm use 20
``` ```
### Rust ### Rust
```bash ```bash
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env source $HOME/.cargo/env
``` ```
@@ -100,166 +170,163 @@ source $HOME/.cargo/env
## Installation ## Installation
```bash ```bash
# Clone the repository
git clone <repository-url> git clone <repository-url>
cd ProxmoxDesktop cd ProxmoxDesktop
# Install Node.js dependencies
npm install npm install
# Install Tauri CLI globally (optional)
npm install -g @tauri-apps/cli
``` ```
The Tauri CLI is a devDependency, so `npm run tauri ...` works without a global install.
## Development ## Development
### Run in Development Mode ### Run in the browser (mock data)
```bash ```bash
# Start the Vite dev server
npm run dev npm run dev
```
# In a separate terminal, run the Tauri app Starts the Vite dev server. The frontend runs in a browser with mock data and no Tauri backend. Useful for UI work.
### Run the desktop app
```bash
npm run tauri dev npm run tauri dev
``` ```
The app will automatically reload when you make changes to the frontend or backend code. Starts the full app. Frontend and backend changes reload automatically.
### Build for Production ### Build
```bash ```bash
# Build the application npm run build # type-check and bundle the frontend
npm run tauri build npm run tauri build # production bundle in src-tauri/target/release/bundle/
``` ```
The built application will be in `src-tauri/target/release/bundle/`. ### Lint
### Run Tests
```bash ```bash
# Run linter
npm run lint npm run lint
# Type check
npm run tsc --noEmit
``` ```
## Testing
```bash
cargo test
```
64 Rust tests cover the API client (against mocked HTTP responses via httpmock), TLS capture and pinning, connection persistence, and every endpoint group. The frontend is exercised in the browser mock mode described above.
### Verifying against a real server
The backend was developed against the documented Proxmox VE API using mocked HTTP responses. There is no live-server test suite yet. To verify against real infrastructure:
1. Run `cargo test`.
2. Run `npm run tauri dev`.
3. Add a connection to a real Proxmox node (`https://host:8006`).
4. Confirm the certificate fingerprint matches the one shown in the Proxmox web UI (Datacenter → Options, or the node's `/etc/pve/local/pve-ssl.pem`).
5. Spot-check VM lifecycle actions, storage, backups, and the console.
Cluster discovery, failover, and same-cluster merging are covered by Rust tests using mocked HTTP (httpmock) and connection-refused simulations on closed ports. To verify them on real hardware, connect to one node of a multi-node cluster and confirm the sidebar lists the other nodes. Stop the connected node's API service, or block its port 8006 with a firewall rule, and check that the connection flips to failover and data keeps loading.
## Configuration
### Proxmox API token
1. In the Proxmox web UI, go to Datacenter → Permissions → API Tokens.
2. Click Add.
3. Select a user (for example, `root@pam`).
4. Enter a token ID (for example, `desktop`).
5. Uncheck Privilege Separation for full access.
6. Copy the token. It looks like `user@realm!tokenid=secret`.
### Connection settings
When adding a connection you need:
- **Name**: a friendly name for the connection
- **Server URL**: for example `https://192.168.1.10:8006`
- **Auth**: an API token, or a username and password (stored in the OS keyring; sessions use ticket auth)
## Project Structure ## Project Structure
``` ```
ProxmoxDesktop/ ProxmoxDesktop/
├── src/ # React frontend ├── src/ # React frontend
│ ├── components/ │ ├── components/
│ │ ├── ui/ # shadcn/ui components │ │ ├── ui/ # base components (buttons, dialogs, toasts)
│ │ ├── layout/ # Layout components (Sidebar, Dashboard) │ │ ├── layout/ # sidebar, dashboard shell
│ │ └── connections/ # Connection management UI │ │ ├── connections/ # connection manager and dialog
│ ├── hooks/ # React Query hooks │ │ ├── dashboard/ # node health grid, gauges, activity feed, quick actions
│ ├── stores/ # Zustand stores │ │ ├── nodes/ # node list and detail
│ ├── lib/ # Utilities and Tauri IPC │ │ ├── vms/ # VM/container lists, detail tabs
│ │ ├── console/ # noVNC and xterm consoles
│ │ ├── storage/ # storage overview and detail
│ │ ├── backups/ # backup jobs and backups
│ │ ├── tasks/ # task list and status bar
│ │ ├── settings/ # settings page, theme switcher
│ │ └── command/ # command palette
│ ├── hooks/ # useProxmox, useWebSocket (TanStack Query)
│ ├── stores/ # Zustand stores (connections, UI)
│ ├── lib/ # tauri.ts IPC bindings, format helpers
│ ├── types/ # TypeScript types │ ├── types/ # TypeScript types
│ ├── App.tsx # Main app component │ ├── App.tsx
│ ├── main.tsx # Entry point │ ├── main.tsx
│ └── index.css # Global styles │ └── index.css
├── src-tauri/ # Tauri/Rust backend ├── src-tauri/ # Tauri/Rust backend
│ ├── src/ │ ├── src/
│ │ ├── main.rs # Entry point │ │ ├── main.rs # binary entry point
│ │ ├── lib.rs # Tauri commands and app setup │ │ ├── lib.rs # Tauri commands, app setup, tray menu
│ │ ├── connection.rs # Connection manager │ │ ├── connection.rs # ConnectionManager, api_request core
│ │ ├── proxmox.rs # Proxmox API types │ │ ├── proxmox.rs # Proxmox API types
│ │ └── error.rs # Error handling │ │ ├── tls.rs # certificate capture and TOFU pinning
│ ├── Cargo.toml # Rust dependencies │ │ ├── websocket.rs # WebSocket event relay
│ └── tauri.conf.json # Tauri configuration │ │ └── error.rs # error mapping
├── package.json # Node.js dependencies │ ├── tests/ # integration tests (httpmock, rcgen, tempfile)
├── vite.config.ts # Vite configuration │ │ ├── api_client.rs
└── tsconfig.json # TypeScript configuration │ │ ├── api_commands.rs
│ │ ├── api_console.rs
│ │ ├── api_disk_network.rs
│ │ ├── api_snapshots_backups.rs
│ │ ├── persistence.rs
│ │ └── tls.rs
│ ├── capabilities/ # Tauri permissions
│ ├── Cargo.toml
│ └── tauri.conf.json
├── package.json
├── vite.config.ts
└── tsconfig.json
``` ```
## Architecture
### Frontend-Backend Communication
The frontend communicates with the Rust backend via Tauri's IPC (Inter-Process Communication):
```
React Component
↓
Tauri IPC (invoke)
↓
Rust Backend
↓
Proxmox API (HTTPS)
```
### Connection Management
The app supports multiple simultaneous connections to Proxmox servers/clusters:
- Each connection has a primary endpoint and optional fallback endpoints
- Automatic failover when the primary endpoint is unreachable
- Credentials stored securely in OS keyring
- Certificate fingerprints cached for TOFU (Trust On First Use)
### State Management
- **TanStack Query**: Server state (API data, caching, refetching)
- **Zustand**: Client state (UI state, active connection, preferences)
## Configuration
### Proxmox API Token
To generate an API token in Proxmox:
1. Go to Datacenter → Permissions → API Tokens
2. Click "Add"
3. Select a user (e.g., root@pam)
4. Enter a Token ID (e.g., "desktop")
5. Uncheck "Privilege Separation" for full access
6. Copy the token (format: `user@realm!tokenid=secret`)
### Connection Settings
When adding a connection, you'll need:
- **Connection Name**: A friendly name for the connection
- **Server URL**: The Proxmox server URL (e.g., `https://192.168.1.10:8006`)
- **API Token**: The API token generated above
## Troubleshooting ## Troubleshooting
### Linux: Missing System Dependencies ### Linux: missing system dependencies
If you see errors about missing libraries:
```bash ```bash
sudo apt install -y libwebkit2gtk-4.1-dev build-essential libssl-dev sudo apt install -y libwebkit2gtk-4.1-dev build-essential libssl-dev
``` ```
### macOS: Code Signing Issues ### macOS: code signing
For development, you may need to allow the app in System Preferences → Security & Privacy. For development builds, allow the app in System Settings → Privacy & Security when prompted.
### Windows: Build Errors ### Windows: build errors
Make sure you have: Make sure you have Visual Studio Build Tools with "Desktop development with C++" and WebView2 (preinstalled on Windows 10 and 11).
- Visual Studio Build Tools with "Desktop development with C++"
- WebView2 (usually pre-installed on Windows 10/11)
### Self-Signed Certificates ### Self-signed certificates
The app will prompt you to trust self-signed certificates on first connection. The certificate fingerprint is stored for future connections. On first connect the app shows the server certificate's SHA-256 fingerprint. Compare it against the fingerprint in the Proxmox web UI before trusting it. If the certificate changes later, the app blocks the connection until you confirm the new fingerprint. Use the "accept anyway" option only when you intentionally replaced the server certificate.
## Contributing ## Contributing
Contributions are welcome! Please: Contributions are welcome. Please:
1. Fork the repository
2. Create a feature branch 1. Fork the repository.
3. Make your changes 2. Create a feature branch.
4. Run `npm run lint` and fix any issues 3. Make your changes.
5. Submit a pull request 4. Run `npm run lint` and `cargo test`, and fix any issues.
5. Submit a pull request.
## License ## License
MIT MIT
## Acknowledgments
- [Proxmox VE](https://www.proxmox.com/en/proxmox-ve) - The amazing virtualization platform
- [Tauri](https://tauri.app/) - Build smaller, faster, more secure desktop apps
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
+20
View File
@@ -8,6 +8,8 @@
"name": "proxmoxdesktop", "name": "proxmoxdesktop",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-dropdown-menu": "^2.1.24",
@@ -561,6 +563,24 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+3 -1
View File
@@ -11,6 +11,8 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-dropdown-menu": "^2.1.24",
@@ -37,13 +39,13 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"esbuild": "^0.28.0",
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/novnc": "^0.0.27", "@types/novnc": "^0.0.27",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"esbuild": "^0.28.0",
"oxlint": "^1.71.0", "oxlint": "^1.71.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~6.0.2", "typescript": "~6.0.2",
View File
+288
View File
@@ -47,6 +47,48 @@ 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 = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-object-pool"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f"
dependencies = [
"async-lock",
"event-listener",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "atk" name = "atk"
version = "0.18.2" version = "0.18.2"
@@ -849,6 +891,26 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -1005,6 +1067,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.33" version = "0.3.33"
@@ -1317,6 +1385,25 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1329,6 +1416,30 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -1396,6 +1507,46 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "httpmock"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e"
dependencies = [
"assert-json-diff",
"async-object-pool",
"async-trait",
"base64 0.22.1",
"bytes",
"crossbeam-utils",
"form_urlencoded",
"futures-timer",
"futures-util",
"headers",
"http",
"http-body-util",
"hyper",
"hyper-util",
"path-tree",
"regex",
"serde",
"serde_json",
"serde_regex",
"similar",
"stringmetrics",
"tabwriter",
"thiserror 2.0.19",
"tokio",
"tracing",
"url",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.11.0" version = "1.11.0"
@@ -1406,9 +1557,11 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -1865,6 +2018,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -2286,6 +2445,12 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2309,6 +2474,25 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "path-tree"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136"
dependencies = [
"smallvec",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -2526,7 +2710,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"httpmock",
"keyring", "keyring",
"percent-encoding",
"rcgen",
"reqwest 0.12.28", "reqwest 0.12.28",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
@@ -2536,8 +2723,10 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-shell", "tauri-plugin-shell",
"tempfile",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tokio-rustls",
"tokio-tungstenite", "tokio-tungstenite",
"url", "url",
"uuid", "uuid",
@@ -2692,6 +2881,19 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2862,6 +3064,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.42" version = "0.23.42"
@@ -3118,6 +3333,16 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_regex"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012"
dependencies = [
"regex",
"serde",
]
[[package]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.21" version = "0.1.21"
@@ -3298,6 +3523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3414,6 +3645,12 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "stringmetrics"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3502,6 +3739,15 @@ dependencies = [
"version-compare", "version-compare",
] ]
[[package]]
name = "tabwriter"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.35.3" version = "0.35.3"
@@ -3809,6 +4055,19 @@ dependencies = [
"toml 1.1.4+spec-1.1.0", "toml 1.1.4+spec-1.1.0",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.1" version = "0.5.1"
@@ -3997,6 +4256,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -4172,10 +4432,23 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -4298,6 +4571,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -5174,6 +5453,15 @@ dependencies = [
"tls_codec", "tls_codec",
] ]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
+7
View File
@@ -20,12 +20,19 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "linux-
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
rustls = "0.23" rustls = "0.23"
rustls-pemfile = "2" rustls-pemfile = "2"
tokio-rustls = "0.26"
x509-cert = "0.2" 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"] }
futures-util = "0.3" futures-util = "0.3"
url = "2" url = "2"
percent-encoding = "2"
[dev-dependencies]
httpmock = "0.8"
rcgen = "0.13"
tempfile = "3"
[features] [features]
default = ["custom-protocol"] default = ["custom-protocol"]
+1 -1
View File
@@ -1 +1 @@
{} {"default":{"identifier":"default","description":"Default capabilities for ProxmoxDesktop","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","shell:allow-execute","shell:default"]}}
File diff suppressed because it is too large Load Diff
+1844 -188
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,9 @@ pub enum Error {
#[error("HTTP request failed: {0}")] #[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error), HttpError(#[from] reqwest::Error),
#[error("Cannot connect to server: {0}")]
ConnectionFailed(String),
#[error("Certificate error: {0}")] #[error("Certificate error: {0}")]
CertificateError(String), CertificateError(String),
+224 -43
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder; use tauri::tray::TrayIconBuilder;
@@ -6,12 +7,19 @@ use tauri::Manager;
use tokio::sync::RwLock; use tokio::sync::RwLock;
mod connection; mod connection;
mod proxmox;
mod error; mod error;
mod proxmox;
pub mod tls;
mod websocket; mod websocket;
use connection::ConnectionManager; pub use connection::{
use error::Error; api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
};
pub use error::Error;
pub use proxmox::{
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
RestoreConfig,
};
use websocket::WebSocketManager; use websocket::WebSocketManager;
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@@ -23,13 +31,36 @@ pub struct ConnectionConfig {
pub name: String, pub name: String,
pub primary: EndpointConfig, pub primary: EndpointConfig,
pub fallbacks: Vec<EndpointConfig>, pub fallbacks: Vec<EndpointConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>, pub cert_fingerprint: Option<String>,
pub trusted: bool, pub trusted: bool,
#[serde(default)]
pub accept_untrusted: bool,
pub status: String, pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cluster_name: Option<String>, pub cluster_name: Option<String>,
pub is_cluster: bool, pub is_cluster: bool,
pub auth_mode: String, pub auth_mode: String,
pub username: Option<String>, pub username: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<DiscoveredNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_id: Option<String>,
}
/// A node discovered in the cluster connected through a
/// [`ConnectionConfig`]. `url` is the endpoint URL derived from the
/// connection's primary endpoint (same scheme and port, host replaced with
/// the node's cluster IP or name); `is_primary` marks the node that the
/// connection is anchored on.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredNode {
pub name: String,
pub url: String,
pub status: String,
pub is_primary: bool,
pub local: bool,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@@ -37,6 +68,7 @@ pub struct ConnectionConfig {
pub struct EndpointConfig { pub struct EndpointConfig {
pub url: String, pub url: String,
pub node: Option<String>, pub node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>, pub token: Option<String>,
} }
@@ -48,7 +80,38 @@ pub struct LoginResult {
pub csrf_token: String, pub csrf_token: String,
} }
#[derive(Clone, Serialize, Deserialize)] /// The outcome of a `connect` attempt. A standalone success reports
/// `merged_into: None`; connecting to a cluster already represented by another
/// connection folds the new endpoint into it and reports
/// `merged_into: Some(target)`. When no endpoint is reachable the connection
/// is still tracked and reported with `status: "failed"` instead of an error.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectResult {
/// The effective connection id to use after the connect (the merge target
/// when this connect merged into an existing same-cluster connection).
pub connection_id: String,
/// `Some(target)` when this connect merged into an existing connection.
pub merged_into: Option<String>,
/// `"connected"` | `"failover"` | `"failed"`.
pub status: String,
}
/// A snapshot of a connection's runtime state for the status bar: the
/// effective status, the primary and currently-serving endpoints, and the last
/// discovered node list.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionStatusInfo {
pub connection_id: String,
/// `"connected"` | `"failover"` | `"failed"` | `"disconnected"`.
pub status: String,
pub primary_url: String,
pub current_endpoint_url: String,
pub nodes: Vec<DiscoveredNode>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CertificateInfo { pub struct CertificateInfo {
pub fingerprint: String, pub fingerprint: String,
@@ -64,38 +127,91 @@ struct AppState {
ws_manager: Arc<RwLock<WebSocketManager>>, ws_manager: Arc<RwLock<WebSocketManager>>,
} }
/// Returns the path of the persisted connections file.
///
/// Lives at `{app_config_dir}/proxmoxdesktop/connections.json`; the parent
/// directory is created lazily when the file is first written.
fn connections_file(app: &tauri::AppHandle) -> Result<PathBuf> {
let dir = app.path().app_config_dir()?;
Ok(dir.join("proxmoxdesktop").join("connections.json"))
}
#[tauri::command]
async fn load_connections(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<LoadResult> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.load_connections(&path).await
}
#[tauri::command] #[tauri::command]
async fn add_connection( async fn add_connection(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
config: ConnectionConfig, config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> { ) -> Result<()> {
let mut manager = state.connection_manager.write().await; let mut manager = state.connection_manager.write().await;
manager.add_connection(config).await let path = connections_file(&app)?;
manager.add_connection(config, &path).await
} }
#[tauri::command] #[tauri::command]
async fn remove_connection( async fn remove_connection(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
app: tauri::AppHandle,
) -> Result<()> { ) -> Result<()> {
let mut manager = state.connection_manager.write().await; let mut manager = state.connection_manager.write().await;
manager.remove_connection(&id).await let path = connections_file(&app)?;
manager.remove_connection(&id, &path).await
}
#[tauri::command]
async fn update_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.update_connection(config, &path).await
} }
#[tauri::command] #[tauri::command]
async fn connect_to_server( async fn connect_to_server(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
) -> Result<()> { app: tauri::AppHandle,
) -> Result<ConnectResult> {
let mut manager = state.connection_manager.write().await; let mut manager = state.connection_manager.write().await;
manager.connect(&id).await let path = connections_file(&app)?;
manager.connect(&id, &path).await
} }
#[tauri::command] #[tauri::command]
async fn disconnect_from_server( async fn get_connection_status(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<ConnectionStatusInfo> {
let mut manager = state.connection_manager.write().await;
manager.status_info(&connection_id).await
}
#[tauri::command]
async fn set_active_connection(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
app: tauri::AppHandle,
) -> Result<()> { ) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.set_active_connection(id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(state: tauri::State<'_, AppState>, id: String) -> Result<()> {
let mut manager = state.connection_manager.write().await; let mut manager = state.connection_manager.write().await;
manager.disconnect(&id).await manager.disconnect(&id).await
} }
@@ -114,9 +230,11 @@ async fn trust_certificate(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
id: String, id: String,
fingerprint: String, fingerprint: String,
app: tauri::AppHandle,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let mut manager = state.connection_manager.write().await;
manager.trust_certificate(&id, &fingerprint).await let path = connections_file(&app)?;
manager.trust_certificate(&id, &fingerprint, &path).await
} }
#[tauri::command] #[tauri::command]
@@ -164,7 +282,9 @@ async fn get_storage_detail(
storage: String, storage: String,
) -> Result<proxmox::StorageDetail> { ) -> Result<proxmox::StorageDetail> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.get_storage_detail(&connection_id, &node, &storage).await manager
.get_storage_detail(&connection_id, &node, &storage)
.await
} }
#[tauri::command] #[tauri::command]
@@ -191,9 +311,12 @@ async fn start_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.start_vm(&connection_id, &node, vmid).await manager
.start_vm(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -202,9 +325,10 @@ async fn stop_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.stop_vm(&connection_id, &node, vmid).await manager.stop_vm(&connection_id, &node, vmid, &vm_type).await
} }
#[tauri::command] #[tauri::command]
@@ -213,9 +337,12 @@ async fn shutdown_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.shutdown_vm(&connection_id, &node, vmid).await manager
.shutdown_vm(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -224,9 +351,12 @@ async fn reboot_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.reboot_vm(&connection_id, &node, vmid).await manager
.reboot_vm(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -235,9 +365,12 @@ async fn suspend_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.suspend_vm(&connection_id, &node, vmid).await manager
.suspend_vm(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -246,9 +379,12 @@ async fn resume_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.resume_vm(&connection_id, &node, vmid).await manager
.resume_vm(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -257,9 +393,12 @@ async fn get_disks(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Disk>> { ) -> Result<Vec<proxmox::Disk>> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.get_disks(&connection_id, &node, vmid).await manager
.get_disks(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -268,10 +407,13 @@ async fn add_disk(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
config: proxmox::AddDiskConfig, config: proxmox::AddDiskConfig,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.add_disk(&connection_id, &node, vmid, config).await manager
.add_disk(&connection_id, &node, vmid, &vm_type, config)
.await
} }
#[tauri::command] #[tauri::command]
@@ -280,11 +422,14 @@ async fn resize_disk(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
disk: String, disk: String,
size: u64, size: u64,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.resize_disk(&connection_id, &node, vmid, &disk, size).await manager
.resize_disk(&connection_id, &node, vmid, &vm_type, &disk, size)
.await
} }
#[tauri::command] #[tauri::command]
@@ -293,10 +438,13 @@ async fn remove_disk(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
disk: String, disk: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.remove_disk(&connection_id, &node, vmid, &disk).await manager
.remove_disk(&connection_id, &node, vmid, &vm_type, &disk)
.await
} }
#[tauri::command] #[tauri::command]
@@ -305,11 +453,14 @@ async fn move_disk(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
disk: String, disk: String,
storage: String, storage: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await manager
.move_disk(&connection_id, &node, vmid, &vm_type, &disk, &storage)
.await
} }
#[tauri::command] #[tauri::command]
@@ -318,9 +469,12 @@ async fn get_network_interfaces(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::NetworkInterface>> { ) -> Result<Vec<proxmox::NetworkInterface>> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.get_network_interfaces(&connection_id, &node, vmid).await manager
.get_network_interfaces(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -329,10 +483,13 @@ async fn add_nic(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
config: proxmox::AddNICConfig, config: proxmox::AddNICConfig,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.add_nic(&connection_id, &node, vmid, config).await manager
.add_nic(&connection_id, &node, vmid, &vm_type, config)
.await
} }
#[tauri::command] #[tauri::command]
@@ -341,11 +498,14 @@ async fn edit_nic(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
nic: String, nic: String,
config: proxmox::EditNICConfig, config: proxmox::EditNICConfig,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.edit_nic(&connection_id, &node, vmid, &nic, config).await manager
.edit_nic(&connection_id, &node, vmid, &vm_type, &nic, config)
.await
} }
#[tauri::command] #[tauri::command]
@@ -354,10 +514,13 @@ async fn remove_nic(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
nic: String, nic: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.remove_nic(&connection_id, &node, vmid, &nic).await manager
.remove_nic(&connection_id, &node, vmid, &vm_type, &nic)
.await
} }
#[tauri::command] #[tauri::command]
@@ -366,9 +529,12 @@ async fn get_snapshots(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Snapshot>> { ) -> Result<Vec<proxmox::Snapshot>> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.get_snapshots(&connection_id, &node, vmid).await manager
.get_snapshots(&connection_id, &node, vmid, &vm_type)
.await
} }
#[tauri::command] #[tauri::command]
@@ -377,10 +543,13 @@ async fn create_snapshot(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
config: proxmox::CreateSnapshotConfig, config: proxmox::CreateSnapshotConfig,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.create_snapshot(&connection_id, &node, vmid, config).await manager
.create_snapshot(&connection_id, &node, vmid, &vm_type, config)
.await
} }
#[tauri::command] #[tauri::command]
@@ -389,10 +558,13 @@ async fn delete_snapshot(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
name: String, name: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.delete_snapshot(&connection_id, &node, vmid, &name).await manager
.delete_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
} }
#[tauri::command] #[tauri::command]
@@ -401,10 +573,13 @@ async fn rollback_snapshot(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
name: String, name: String,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.rollback_snapshot(&connection_id, &node, vmid, &name).await manager
.rollback_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
} }
#[tauri::command] #[tauri::command]
@@ -413,11 +588,14 @@ async fn migrate_vm(
connection_id: String, connection_id: String,
node: String, node: String,
vmid: u32, vmid: u32,
vm_type: String,
target_node: String, target_node: String,
online: bool, online: bool,
) -> Result<()> { ) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await manager
.migrate_vm(&connection_id, &node, vmid, &vm_type, &target_node, online)
.await
} }
// Authentication commands // Authentication commands
@@ -429,7 +607,9 @@ async fn login_with_password(
password: String, password: String,
) -> Result<LoginResult> { ) -> Result<LoginResult> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.login_with_password(&url, &username, &password).await manager
.login_with_password(&url, &username, &password)
.await
} }
#[tauri::command] #[tauri::command]
@@ -443,10 +623,7 @@ async fn login_with_token(
} }
#[tauri::command] #[tauri::command]
async fn logout( async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> {
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<()> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.logout(&connection_id).await manager.logout(&connection_id).await
} }
@@ -461,7 +638,7 @@ async fn get_stored_credentials(
} }
// Console proxy types // Console proxy types
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VNCProxyResponse { pub struct VNCProxyResponse {
pub ticket: String, pub ticket: String,
@@ -469,7 +646,7 @@ pub struct VNCProxyResponse {
pub cert: String, pub cert: String,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TermProxyResponse { pub struct TermProxyResponse {
pub ticket: String, pub ticket: String,
@@ -554,7 +731,9 @@ async fn get_backups(
storage: Option<String>, storage: Option<String>,
) -> Result<Vec<proxmox::Backup>> { ) -> Result<Vec<proxmox::Backup>> {
let manager = state.connection_manager.read().await; let manager = state.connection_manager.read().await;
manager.get_backups(&connection_id, storage.as_deref()).await manager
.get_backups(&connection_id, storage.as_deref())
.await
} }
#[tauri::command] #[tauri::command]
@@ -689,10 +868,14 @@ pub fn run() {
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())), ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
load_connections,
add_connection, add_connection,
remove_connection, remove_connection,
update_connection,
set_active_connection,
connect_to_server, connect_to_server,
disconnect_from_server, disconnect_from_server,
get_connection_status,
login_with_password, login_with_password,
login_with_token, login_with_token,
logout, logout,
@@ -759,8 +942,7 @@ pub fn run() {
.tooltip("ProxmoxDesktop") .tooltip("ProxmoxDesktop")
.icon(app.default_window_icon().cloned().expect("no default icon")) .icon(app.default_window_icon().cloned().expect("no default icon"))
.menu(&menu) .menu(&menu)
.on_menu_event(move |app, event| { .on_menu_event(move |app, event| match event.id.as_ref() {
match event.id.as_ref() {
"show_hide" => { "show_hide" => {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) { if window.is_visible().unwrap_or(false) {
@@ -775,7 +957,6 @@ pub fn run() {
app.exit(0); app.exit(0);
} }
_ => {} _ => {}
}
}) })
.build(app)?; .build(app)?;
+54
View File
@@ -7,6 +7,7 @@ pub struct Disk {
pub size: u64, pub size: u64,
pub storage: String, pub storage: String,
pub format: String, pub format: String,
#[serde(default)]
pub usage: Option<String>, pub usage: Option<String>,
} }
@@ -21,7 +22,9 @@ pub struct AddDiskConfig {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Node { pub struct Node {
#[serde(default)]
pub node: String, pub node: String,
#[serde(default)]
pub status: String, pub status: String,
pub cpu: f64, pub cpu: f64,
pub maxcpu: u32, pub maxcpu: u32,
@@ -38,25 +41,44 @@ pub struct Node {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct VM { pub struct VM {
#[serde(default)]
pub vmid: u32, pub vmid: u32,
#[serde(default)]
pub name: Option<String>, pub name: Option<String>,
#[serde(default)]
pub status: String, pub status: String,
pub r#type: String, pub r#type: String,
#[serde(default)]
pub node: String, pub node: String,
#[serde(default)]
pub cpu: f64, pub cpu: f64,
#[serde(default)]
pub cpus: u32, pub cpus: u32,
#[serde(default)]
pub mem: u64, pub mem: u64,
#[serde(default)]
pub maxmem: u64, pub maxmem: u64,
#[serde(default)]
pub disk: u64, pub disk: u64,
#[serde(default)]
pub maxdisk: u64, pub maxdisk: u64,
#[serde(default)]
pub uptime: u64, pub uptime: u64,
#[serde(default)]
pub netin: u64, pub netin: u64,
#[serde(default)]
pub netout: u64, pub netout: u64,
#[serde(default)]
pub diskread: u64, pub diskread: u64,
#[serde(default)]
pub diskwrite: u64, pub diskwrite: u64,
#[serde(default)]
pub pid: Option<u32>, pub pid: Option<u32>,
#[serde(default)]
pub template: Option<u32>, pub template: Option<u32>,
#[serde(default)]
pub lock: Option<String>, pub lock: Option<String>,
#[serde(default)]
pub tags: Option<String>, pub tags: Option<String>,
} }
@@ -66,12 +88,19 @@ pub struct Storage {
pub storage: String, pub storage: String,
pub r#type: String, pub r#type: String,
pub content: String, pub content: String,
#[serde(default)]
pub active: u32, pub active: u32,
#[serde(default)]
pub enabled: u32, pub enabled: u32,
#[serde(default)]
pub shared: u32, pub shared: u32,
#[serde(default)]
pub used: u64, pub used: u64,
#[serde(default)]
pub total: u64, pub total: u64,
#[serde(default)]
pub avail: u64, pub avail: u64,
#[serde(default)]
pub node: String, pub node: String,
} }
@@ -79,15 +108,19 @@ pub struct Storage {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Task { pub struct Task {
pub upid: String, pub upid: String,
#[serde(default)]
pub node: String, pub node: String,
pub pid: u32, pub pid: u32,
pub pstart: u64, pub pstart: u64,
pub starttime: u64, pub starttime: u64,
#[serde(default)]
pub endtime: Option<u64>, pub endtime: Option<u64>,
pub r#type: String, pub r#type: String,
pub id: String, pub id: String,
pub user: String, pub user: String,
#[serde(default)]
pub status: Option<String>, pub status: Option<String>,
#[serde(default)]
pub exitstatus: Option<String>, pub exitstatus: Option<String>,
} }
@@ -114,9 +147,11 @@ pub struct ClusterNode {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Snapshot { pub struct Snapshot {
pub name: String, pub name: String,
#[serde(default)]
pub description: String, pub description: String,
pub snaptime: u64, pub snaptime: u64,
pub vmstate: u32, pub vmstate: u32,
#[serde(default)]
pub parent: Option<String>, pub parent: Option<String>,
} }
@@ -140,9 +175,13 @@ pub struct NetworkInterface {
pub name: String, pub name: String,
pub model: String, pub model: String,
pub macaddr: String, pub macaddr: String,
#[serde(default)]
pub bridge: Option<String>, pub bridge: Option<String>,
#[serde(default)]
pub tag: Option<u32>, pub tag: Option<u32>,
#[serde(default)]
pub firewall: Option<u32>, pub firewall: Option<u32>,
#[serde(default)]
pub link_down: Option<u32>, pub link_down: Option<u32>,
} }
@@ -189,10 +228,15 @@ pub struct BackupJob {
pub schedule: String, pub schedule: String,
pub all: u32, pub all: u32,
pub enabled: u32, pub enabled: u32,
#[serde(default)]
pub node: Option<String>, pub node: Option<String>,
#[serde(default)]
pub vmid: Option<String>, pub vmid: Option<String>,
#[serde(default)]
pub compress: Option<String>, pub compress: Option<String>,
#[serde(default)]
pub mode: Option<String>, pub mode: Option<String>,
#[serde(default)]
pub quiet: Option<u32>, pub quiet: Option<u32>,
} }
@@ -224,8 +268,11 @@ pub struct RestoreConfig {
pub struct StorageContent { pub struct StorageContent {
pub content: String, pub content: String,
pub ctime: u64, pub ctime: u64,
#[serde(default)]
pub format: Option<String>, pub format: Option<String>,
#[serde(default)]
pub size: Option<u64>, pub size: Option<u64>,
#[serde(default)]
pub subtype: Option<String>, pub subtype: Option<String>,
pub volid: String, pub volid: String,
} }
@@ -236,11 +283,18 @@ pub struct StorageDetail {
pub storage: String, pub storage: String,
pub r#type: String, pub r#type: String,
pub content: String, pub content: String,
#[serde(default)]
pub active: u32, pub active: u32,
#[serde(default)]
pub enabled: u32, pub enabled: u32,
#[serde(default)]
pub shared: u32, pub shared: u32,
#[serde(default)]
pub used: u64, pub used: u64,
#[serde(default)]
pub total: u64, pub total: u64,
#[serde(default)]
pub avail: u64, pub avail: u64,
#[serde(default)]
pub node: String, pub node: String,
} }
+218
View File
@@ -0,0 +1,218 @@
//! Application-layer TLS certificate capture and TOFU (Trust On First Use)
//! fingerprint pinning.
//!
//! The reqwest transport used by the rest of the app is configured to accept
//! self-signed and otherwise invalid certificates, which is typical for
//! home-lab Proxmox servers. To keep connections safe without a full CA trust
//! chain, this module performs its own certificate capture at the connect
//! layer: it opens a raw TLS connection, extracts the presented leaf
//! certificate, and computes its SHA-256 fingerprint. [`verify_server_certificate`]
//! then compares that fingerprint against the value pinned when the server was
//! first trusted.
use crate::{CertificateInfo, Error};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use url::Url;
use x509_cert::der::Decode;
/// Verifier that accepts every presented certificate chain.
///
/// The actual security decision is deferred to the application layer: the leaf
/// certificate's SHA-256 fingerprint is captured on each connection and
/// compared against the pinned value recorded on first use (TOFU).
#[derive(Debug)]
struct AcceptAllVerifier;
impl ServerCertVerifier for AcceptAllVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Installs the ring crypto provider once per process.
///
/// `install_default` is idempotent and thread-safe; subsequent calls return
/// `Err` with the already-installed provider, which is ignored here.
fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
/// Opens a raw TLS connection to `url` and returns the DER-encoded leaf
/// certificate presented by the server.
async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
ensure_crypto_provider();
let parsed =
Url::parse(url).map_err(|e| Error::InvalidUrl(format!("Invalid URL '{}': {}", url, e)))?;
if parsed.scheme() != "https" {
return Err(Error::InvalidUrl(format!(
"Only https URLs are supported, got '{}://'",
parsed.scheme()
)));
}
let host = parsed
.host_str()
.ok_or_else(|| Error::InvalidUrl(format!("URL '{}' has no host", url)))?
.to_string();
let port = parsed.port().unwrap_or(443);
let server_name = ServerName::try_from(host.clone()).map_err(|e| {
Error::InvalidUrl(format!(
"Invalid hostname '{}' in URL '{}': {}",
host, url, e
))
})?;
let addr = format!("{}:{}", host, port);
let addresses = tokio::net::lookup_host(&addr)
.await
.map_err(|e| Error::InvalidUrl(format!("Cannot resolve '{}': {}", addr, e)))?
.collect::<Vec<_>>();
let address = addresses
.first()
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
let tcp = TcpStream::connect(address)
.await
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAllVerifier))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let tls = connector.connect(server_name, tcp).await.map_err(|e| {
Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e))
})?;
let (_, session) = tls.get_ref();
let leaf = session
.peer_certificates()
.and_then(|certs| certs.first())
.ok_or_else(|| {
Error::CertificateError(format!("Server '{}' presented no TLS certificate", addr))
})?;
Ok(leaf.as_ref().to_vec())
}
/// Computes the SHA-256 fingerprint of a DER-encoded certificate in the
/// `AA:BB:CC:...` uppercase colon-separated format used across the app.
fn fingerprint_of(der: &[u8]) -> String {
let digest = Sha256::digest(der);
let hex = hex::encode_upper(digest);
hex.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
.collect::<Vec<_>>()
.join(":")
}
/// Captures just the SHA-256 fingerprint of the certificate presented by `url`.
pub async fn capture_fingerprint(url: &str) -> crate::Result<String> {
let der = capture_leaf_certificate_der(url).await?;
Ok(fingerprint_of(&der))
}
/// Compares a captured fingerprint against a pinned value.
///
/// The comparison is case-insensitive and ignores the `:` (and other
/// non-hex) separators, so `ab:cd` and `AB:CD` (or even `abcd`) all match.
pub fn verify_pin(fingerprint: &str, pinned: &str) -> bool {
fn normalize(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(char::to_uppercase)
.collect()
}
normalize(fingerprint) == normalize(pinned)
}
/// Connects to `url`, captures the presented leaf certificate, and returns a
/// [`CertificateInfo`] with its fingerprint and parsed metadata.
pub async fn fetch_certificate_info(url: &str) -> crate::Result<CertificateInfo> {
let der = capture_leaf_certificate_der(url).await?;
let cert = x509_cert::Certificate::from_der(&der)
.map_err(|e| Error::CertificateError(format!("Failed to parse certificate: {}", e)))?;
let tbs = &cert.tbs_certificate;
let issuer = tbs.issuer.to_string();
let subject = tbs.subject.to_string();
let self_signed = issuer == subject;
Ok(CertificateInfo {
fingerprint: fingerprint_of(&der),
issuer,
subject,
valid_from: tbs.validity.not_before.to_string(),
valid_to: tbs.validity.not_after.to_string(),
self_signed,
})
}
/// Captures the certificate fingerprint of `url` and verifies it against the
/// pinned fingerprint recorded on first use.
///
/// * `pinned == None`: first use — returns the fingerprint so the caller can
/// record it (the TOFU trust step).
/// * `pinned == Some(...)` and it matches the captured fingerprint: `Ok`.
/// * `pinned == Some(...)` and it does not match: returns
/// [`Error::CertificateError`] unless `accept_untrusted` is set, in which
/// case the mismatch is ignored (escape hatch).
///
/// The returned value is the captured fingerprint.
pub async fn verify_server_certificate(
url: &str,
pinned: Option<&str>,
accept_untrusted: bool,
) -> crate::Result<String> {
let fingerprint = capture_fingerprint(url).await?;
if let Some(pinned) = pinned {
if !verify_pin(&fingerprint, pinned) && !accept_untrusted {
return Err(Error::CertificateError(format!(
"Certificate fingerprint changed. Expected '{}' but got '{}'. \
This could be a man-in-the-middle attack.",
pinned, fingerprint
)));
}
}
Ok(fingerprint)
}
+2 -5
View File
@@ -4,8 +4,8 @@ use std::collections::HashMap;
use tauri::Emitter; use tauri::Emitter;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use crate::error::Error; use crate::error::Error;
@@ -236,10 +236,7 @@ fn handle_ws_message(connection_id: &str, text: &str, app_handle: &tauri::AppHan
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
vmid: data vmid: data.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
.get("vmid")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
status: data status: data
.get("status") .get("status")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":6524788087311550793,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: aarch64-apple-darwin\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/matt/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}} {"rustc_fingerprint":9200552825474144316,"outputs":{"7752308220390268658":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs:
@@ -1,8 +1,8 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs /home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs:
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs:
+213
View File
@@ -0,0 +1,213 @@
//! Integration tests for the Proxmox API client core.
//!
//! These tests exercise the generic `api_request` helper against a local HTTP
//! mock server, covering auth header injection, response envelope unwrapping,
//! error mapping, query parameters, and URL construction.
use httpmock::prelude::*;
use proxmox_desktop::{api_request, AuthContext, AuthMode, Error};
use reqwest::Client;
use reqwest::Method as RMethod;
const TICKET: &str = "PVE:root@pam:abc123";
const CSRF_TOKEN: &str = "csrf-token-123";
fn token_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Token,
token: Some("root@pam!test-token".to_string()),
ticket: None,
csrf_token: None,
}
}
fn password_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Password,
token: None,
ticket: Some(TICKET.to_string()),
csrf_token: Some(CSRF_TOKEN.to_string()),
}
}
#[tokio::test]
async fn token_auth_sends_authorization_header_and_unwraps_envelope() {
let server = MockServer::start();
let expected = serde_json::json!({
"version": "8.1.0",
"release": "8.1",
"repoid": "abc123",
});
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", "PVEAPIToken=root@pam!test-token");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": expected }).to_string());
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result, expected);
mock.assert();
}
#[tokio::test]
async fn password_auth_sends_cookie_header_on_get() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Cookie", format!("PVEAuthCookie={}", TICKET));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.1.0"}}"#);
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&password_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result["version"], "8.1.0");
mock.assert();
}
#[tokio::test]
async fn password_auth_sends_cookie_and_csrf_headers_on_post() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve/qemu/100/status/start")
.header("Cookie", format!("PVEAuthCookie={}", TICKET))
.header("CSRFPreventionToken", CSRF_TOKEN);
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::POST,
"/nodes/pve/qemu/100/status/start",
&password_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result, serde_json::Value::Null);
mock.assert();
}
#[tokio::test]
async fn server_error_message_is_mapped_to_api_error() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(500)
.header("content-type", "application/json")
.body(r#"{"message":"boom"}"#);
});
let error = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("request should fail");
assert!(
matches!(error, Error::ApiError(ref message) if message == "boom"),
"expected ApiError with message 'boom', got: {}",
error
);
mock.assert();
}
#[tokio::test]
async fn query_params_are_included_in_request() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve/qemu")
.query_param("node", "pve")
.query_param("vmid", "100")
.query_param("include-config", "true");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let query = [
("node", "pve".to_string()),
("vmid", "100".to_string()),
("include-config", "true".to_string()),
];
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/nodes/pve/qemu",
&token_auth(),
&query,
None,
)
.await
.expect("request should succeed");
assert_eq!(result, serde_json::json!([]));
mock.assert();
}
#[tokio::test]
async fn base_url_trailing_slash_is_stripped() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.1.0"}}"#);
});
let base_url = format!("{}/", server.base_url());
let result = api_request(
&Client::new(),
&base_url,
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result["version"], "8.1.0");
mock.assert();
}
+570
View File
@@ -0,0 +1,570 @@
//! Integration tests for the real Proxmox data-read API calls and the
//! VM/container lifecycle operations.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction, query parameters, auth header injection, response envelope
//! unwrapping, and mapping into the public serde structs. Token-mode
//! connections resolve the token from the config, so the read methods can be
//! called directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server. `node` pins the storage node.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&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: node.map(str::to_string),
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_nodes_maps_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!nodes-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": 16,
"mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64,
"maxdisk": 858993459200u64, "uptime": 123456, "level": "",
"id": "node/pve1", "type": "node"},
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve2", "type": "node"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let nodes = manager
.get_nodes("conn")
.await
.expect("nodes should be fetched");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].node, "pve1");
assert_eq!(nodes[0].status, "online");
assert!((nodes[0].cpu - 0.12).abs() < 1e-9);
assert_eq!(nodes[0].maxcpu, 16);
assert_eq!(nodes[0].mem, 8589934592u64);
assert_eq!(nodes[0].maxmem, 68719476736u64);
assert_eq!(nodes[0].disk, 214748364800u64);
assert_eq!(nodes[0].maxdisk, 858993459200u64);
assert_eq!(nodes[0].uptime, 123456);
assert_eq!(nodes[0].id, "node/pve1");
assert_eq!(nodes[0].r#type, "node");
assert_eq!(nodes[1].node, "pve2");
assert_eq!(nodes[1].status, "offline");
mock.assert();
}
#[tokio::test]
async fn get_vms_uses_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!vms-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
"node": "pve1", "cpu": 0.03, "cpus": 2, "maxcpu": 2,
"mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64,
"maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456,
"diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod",
"pid": 1234},
// A stopped container: the API omits runtime stats.
{"vmid": 201, "name": "ct01", "type": "lxc", "status": "stopped",
"node": "pve2", "template": 1, "tags": ""}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let vms = manager
.get_vms("conn")
.await
.expect("VMs should be fetched");
assert_eq!(vms.len(), 2);
assert_eq!(vms[0].vmid, 100);
assert_eq!(vms[0].name.as_deref(), Some("web01"));
assert_eq!(vms[0].r#type, "qemu");
assert_eq!(vms[0].status, "running");
assert_eq!(vms[0].node, "pve1");
assert_eq!(vms[0].uptime, 99999);
assert_eq!(vms[0].netin, 123);
assert_eq!(vms[0].netout, 456);
assert_eq!(vms[0].diskread, 789);
assert_eq!(vms[0].diskwrite, 101112);
assert_eq!(vms[0].tags.as_deref(), Some("prod"));
assert_eq!(vms[0].pid, Some(1234));
// Tolerance: fields omitted for the stopped LXC default instead of failing.
assert_eq!(vms[1].r#type, "lxc");
assert_eq!(vms[1].status, "stopped");
assert_eq!(vms[1].uptime, 0);
assert_eq!(vms[1].cpu, 0.0);
assert_eq!(vms[1].netin, 0);
assert_eq!(vms[1].pid, None);
mock.assert();
}
#[tokio::test]
async fn get_storage_maps_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!storage-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "storage");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"storage": "local", "node": "pve1", "type": "dir",
"content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
"total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
"status": "available"},
{"storage": "backup", "node": "pve1", "type": "nfs",
"content": "backup", "enabled": 1, "shared": 1, "active": 1,
"total": 1717986918400u64, "used": 644245094400u64,
"avail": 1073741824000u64, "status": "available"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let storages = manager
.get_storage("conn")
.await
.expect("storage should be fetched");
assert_eq!(storages.len(), 2);
assert_eq!(storages[0].storage, "local");
assert_eq!(storages[0].r#type, "dir");
assert_eq!(storages[0].content, "iso,vztmpl");
assert_eq!(storages[0].active, 1);
assert_eq!(storages[0].enabled, 1);
assert_eq!(storages[0].shared, 0);
assert_eq!(storages[0].used, 429496729600u64);
assert_eq!(storages[0].total, 858993459200u64);
assert_eq!(storages[0].avail, 429496729600u64);
assert_eq!(storages[0].node, "pve1");
assert_eq!(storages[1].shared, 1);
mock.assert();
}
#[tokio::test]
async fn get_storage_content_uses_configured_node() {
let server = MockServer::start();
let token = "root@pam!content-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"volid": "local:iso/debian-12.iso", "content": "iso",
"ctime": 1700000000, "format": "iso", "size": 68719476736u64},
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
"ctime": 1700000001}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let contents = manager
.get_storage_content("conn", "local")
.await
.expect("content should be fetched");
assert_eq!(contents.len(), 2);
assert_eq!(contents[0].volid, "local:iso/debian-12.iso");
assert_eq!(contents[0].content, "iso");
assert_eq!(contents[0].format.as_deref(), Some("iso"));
assert_eq!(contents[0].size, Some(68719476736u64));
// Tolerance: optional metadata is omitted for the second entry.
assert_eq!(contents[1].format, None);
assert_eq!(contents[1].subtype, None);
mock.assert();
}
#[tokio::test]
async fn get_storage_content_falls_back_to_online_node() {
let server = MockServer::start();
let token = "root@pam!fallback-token";
let nodes_mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"node": "pve1", "status": "online", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve1", "type": "node"},
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve2", "type": "node"}
]
})
.to_string(),
);
});
let content_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"volid": "local:iso/debian.iso", "content": "iso",
"ctime": 1700000000}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let contents = manager
.get_storage_content("conn", "local")
.await
.expect("content should be fetched");
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].volid, "local:iso/debian.iso");
nodes_mock.assert();
content_mock.assert();
}
#[tokio::test]
async fn get_storage_detail_maps_status() {
let server = MockServer::start();
let token = "root@pam!detail-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
"total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let detail = manager
.get_storage_detail("conn", "pve1", "local")
.await
.expect("detail should be fetched");
assert_eq!(detail.storage, "local");
assert_eq!(detail.r#type, "dir");
assert_eq!(detail.content, "iso,vztmpl");
assert_eq!(detail.active, 1);
assert_eq!(detail.enabled, 1);
assert_eq!(detail.used, 429496729600u64);
assert_eq!(detail.total, 858993459200u64);
assert_eq!(detail.avail, 429496729600u64);
assert_eq!(detail.node, "pve1");
mock.assert();
}
#[tokio::test]
async fn get_tasks_maps_cluster_tasks() {
let server = MockServer::start();
let token = "root@pam!tasks-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/tasks")
.query_param("limit", "50");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"upid": "UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:",
"node": "pve1", "pid": 1000, "pstart": 100, "starttime": 1700000000,
"type": "qmpstart", "id": "100", "user": "root@pam",
"status": "stopped", "endtime": 1700000100, "exitstatus": "OK"},
{"upid": "UPID:pve2:00000002:00000002:5F000001:vzstart:201:root@pam:",
"node": "pve2", "pid": 2000, "pstart": 200, "starttime": 1700000001,
"type": "vzstart", "id": "201", "user": "root@pam"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let tasks = manager
.get_tasks("conn")
.await
.expect("tasks should be fetched");
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks[0].upid,
"UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:"
);
assert_eq!(tasks[0].node, "pve1");
assert_eq!(tasks[0].pid, 1000);
assert_eq!(tasks[0].pstart, 100);
assert_eq!(tasks[0].starttime, 1700000000);
assert_eq!(tasks[0].r#type, "qmpstart");
assert_eq!(tasks[0].id, "100");
assert_eq!(tasks[0].user, "root@pam");
assert_eq!(tasks[0].endtime, Some(1700000100));
assert_eq!(tasks[0].status.as_deref(), Some("stopped"));
assert_eq!(tasks[0].exitstatus.as_deref(), Some("OK"));
// Tolerance: the second task has not finished yet.
assert_eq!(tasks[1].endtime, None);
assert_eq!(tasks[1].status, None);
assert_eq!(tasks[1].exitstatus, None);
mock.assert();
}
#[tokio::test]
async fn get_cluster_status_builds_cluster() {
let server = MockServer::start();
let token = "root@pam!cluster-token";
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"type": "cluster", "id": "cluster", "name": "proxmox-cluster",
"nodes": 2},
{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1,
"local": 1, "ip": "10.0.0.1"},
{"type": "node", "id": "node/pve2", "nodeid": 2, "online": 0,
"ip": "10.0.0.2"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let status = manager
.get_cluster_status("conn")
.await
.expect("cluster status should be fetched");
assert_eq!(status.r#type, "cluster");
assert_eq!(status.name, "proxmox-cluster");
assert_eq!(status.id, "cluster");
let nodes = status.nodes.expect("nodes should be present");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].name, "pve1");
assert_eq!(nodes[0].nodeid, 1);
assert_eq!(nodes[0].online, 1);
assert_eq!(nodes[0].local, Some(1));
assert_eq!(nodes[0].ip.as_deref(), Some("10.0.0.1"));
assert_eq!(nodes[1].name, "pve2");
assert_eq!(nodes[1].online, 0);
assert_eq!(nodes[1].local, None);
mock.assert();
}
#[tokio::test]
async fn get_cluster_status_falls_back_to_default_name() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!default-token", None).await;
let status = manager
.get_cluster_status("conn")
.await
.expect("status should be fetched");
assert_eq!(status.name, "default");
assert_eq!(status.id, "");
let nodes = status.nodes.expect("nodes should be present");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].name, "pve1");
mock.assert();
}
#[tokio::test]
async fn lifecycle_uses_qemu_or_lxc_path() {
let server = MockServer::start();
let token = "root@pam!lifecycle-token";
let start_qemu = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/status/start")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let start_lxc = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/lxc/201/status/start");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let stop_qemu = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/status/stop");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.start_vm("conn", "pve1", 100, "qemu")
.await
.expect("qemu start should succeed");
manager
.start_vm("conn", "pve1", 201, "lxc")
.await
.expect("lxc start should succeed");
manager
.stop_vm("conn", "pve1", 100, "qemu")
.await
.expect("qemu stop should succeed");
start_qemu.assert();
start_lxc.assert();
stop_qemu.assert();
}
#[tokio::test]
async fn lifecycle_with_invalid_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/kvm/100/status/start");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.start_vm("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.hits(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
#[tokio::test]
async fn read_methods_error_with_connection_not_found() {
let server = MockServer::start();
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token", None).await;
let error = manager
.get_nodes("does-not-exist")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
let error = manager
.get_vms("does-not-exist")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
let error = manager
.start_vm("does-not-exist", "pve1", 100, "qemu")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
}
+168
View File
@@ -0,0 +1,168 @@
//! Integration tests for the console proxy API calls (VNC + terminal) and
//! the WebSocket base URL derivation.
//!
//! The proxy calls go through `Connection::request`, so these tests cover URL
//! construction, auth header injection, response envelope unwrapping, and
//! mapping into the public serde structs. `get_websocket_url` only rewrites
//! the stored endpoint URL, so it never hits the network. Token-mode
//! connections resolve the token from the config, so the methods can be called
//! directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at `url`.
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn create_vnc_proxy_maps_response() {
let server = MockServer::start();
let token = "root@pam!vnc-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/vncproxy")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"ticket": "PVE:vnc:abc",
"port": 6000,
"cert": "MIIB..."
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
let proxy = manager
.create_vnc_proxy("conn", "pve1", 100)
.await
.expect("vnc proxy should be created");
assert_eq!(proxy.ticket, "PVE:vnc:abc");
assert_eq!(proxy.port, 6000);
assert_eq!(proxy.cert, "MIIB...");
mock.assert();
}
#[tokio::test]
async fn create_term_proxy_maps_response() {
let server = MockServer::start();
let token = "root@pam!term-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/lxc/201/termproxy")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"ticket": "PVE:term:xyz",
"port": 6100
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
let proxy = manager
.create_term_proxy("conn", "pve1", 201)
.await
.expect("term proxy should be created");
assert_eq!(proxy.ticket, "PVE:term:xyz");
assert_eq!(proxy.port, 6100);
mock.assert();
}
#[tokio::test]
async fn get_websocket_url_converts_https_to_wss() {
// Explicit port on https.
let (manager, _dir) = setup_manager("https://192.168.1.10:8006", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "wss://192.168.1.10:8006");
// No explicit port: the default wss port applies, so none is emitted.
let (manager, _dir) = setup_manager("https://pve.lan", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "wss://pve.lan");
// Plain http maps to ws.
let (manager, _dir) = setup_manager("http://10.0.0.5:8006", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "ws://10.0.0.5:8006");
}
#[tokio::test]
async fn get_websocket_url_errors_on_invalid_scheme() {
let (manager, _dir) = setup_manager("ftp://host", "root@pam!ws-token").await;
let error = manager
.get_websocket_url("conn", "pve1")
.await
.expect_err("non-http(s) scheme must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("ftp")),
"expected InvalidUrl mentioning 'ftp', got: {}",
error
);
}
#[tokio::test]
async fn missing_connection_errors() {
let server = MockServer::start();
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token").await;
let error = manager
.create_vnc_proxy("does-not-exist", "pve1", 100)
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
}
+465
View File
@@ -0,0 +1,465 @@
//! Integration tests for VM/container disk and network interface management.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction (including the `qemu`/`lxc` path segment), form encoding,
//! config parsing, and slot allocation. Token-mode connections resolve the
//! token from the config, so the methods can be called directly on an added
//! connection without `connect()`.
//!
//! The form bodies are `application/x-www-form-urlencoded`, so values with
//! special characters (`:`, `=`, `,`) appear percent-encoded in the raw body;
//! `body_includes` assertions use the encoded form (e.g. `:` becomes `%3A`).
use httpmock::prelude::*;
use proxmox_desktop::{
AddDiskConfig, AddNICConfig, ConnectionConfig, ConnectionManager, EditNICConfig,
EndpointConfig, Error,
};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&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: node.map(str::to_string),
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_disks_parses_config_strings() {
let server = MockServer::start();
let token = "root@pam!disks-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"scsi0": "local-lvm:vm-100-disk-0,size=32G,format=qcow2",
"scsi1": "local-lvm:vm-100-disk-1,size=50G",
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0",
"memory": 2048
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let disks = manager
.get_disks("conn", "pve1", 100, "qemu")
.await
.expect("disks should be fetched");
assert_eq!(disks.len(), 2);
assert_eq!(disks[0].device, "scsi0");
assert_eq!(disks[0].storage, "local-lvm");
assert_eq!(disks[0].size, 34359738368);
assert_eq!(disks[0].format, "qcow2");
assert_eq!(disks[0].usage, None);
// The second disk has no size/format attributes: size is 0, format empty.
assert_eq!(disks[1].device, "scsi1");
assert_eq!(disks[1].storage, "local-lvm");
assert_eq!(disks[1].size, 53687091200);
assert_eq!(disks[1].format, "");
mock.assert();
}
#[tokio::test]
async fn add_disk_picks_free_slot_and_posts() {
let server = MockServer::start();
let token = "root@pam!add-disk-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"scsi0": "local-lvm:vm-100-disk-0,size=32G"}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
// urlencoded: the `:` in `local-lvm:64G` becomes `%3A`.
.body_includes("scsi1=local-lvm%3A64G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = AddDiskConfig {
storage: "local-lvm".to_string(),
size: 68719476736,
bus_type: "scsi".to_string(),
};
manager
.add_disk("conn", "pve1", 100, "qemu", config)
.await
.expect("disk should be added");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn resize_disk_posts_resize() {
let server = MockServer::start();
let token = "root@pam!resize-token";
let mock = server.mock(|when, then| {
when.method(PUT)
.path("/api2/json/nodes/pve1/qemu/100/resize")
.body_includes("disk=scsi0")
.body_includes("size=50G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.resize_disk("conn", "pve1", 100, "qemu", "scsi0", 53687091200)
.await
.expect("resize should succeed");
mock.assert();
}
#[tokio::test]
async fn remove_disk_posts_delete() {
let server = MockServer::start();
let token = "root@pam!remove-disk-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("delete=scsi0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.remove_disk("conn", "pve1", 100, "qemu", "scsi0")
.await
.expect("disk should be removed");
mock.assert();
}
#[tokio::test]
async fn move_disk_posts_move_disk() {
let server = MockServer::start();
let token = "root@pam!move-disk-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/move_disk")
.body_includes("disk=scsi0")
.body_includes("storage=local-zfs");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.move_disk("conn", "pve1", 100, "qemu", "scsi0", "local-zfs")
.await
.expect("disk should be moved");
mock.assert();
}
#[tokio::test]
async fn get_network_interfaces_parses_nets() {
let server = MockServer::start();
let token = "root@pam!nets-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1,link_down=1",
"net1": "e1000=11:22:33:44:55:66,bridge=vmbr1"
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let nics = manager
.get_network_interfaces("conn", "pve1", 100, "qemu")
.await
.expect("nics should be fetched");
assert_eq!(nics.len(), 2);
assert_eq!(nics[0].name, "net0");
assert_eq!(nics[0].model, "virtio");
assert_eq!(nics[0].macaddr, "BC:24:11:AA:BB:CC");
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
assert_eq!(nics[0].tag, Some(10));
assert_eq!(nics[0].firewall, Some(1));
assert_eq!(nics[0].link_down, Some(1));
// The second NIC has no optional attributes.
assert_eq!(nics[1].name, "net1");
assert_eq!(nics[1].model, "e1000");
assert_eq!(nics[1].macaddr, "11:22:33:44:55:66");
assert_eq!(nics[1].bridge.as_deref(), Some("vmbr1"));
assert_eq!(nics[1].tag, None);
assert_eq!(nics[1].firewall, None);
assert_eq!(nics[1].link_down, None);
mock.assert();
}
#[tokio::test]
async fn add_nic_posts_form() {
let server = MockServer::start();
let token = "root@pam!add-nic-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"memory": 2048}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
// urlencoded: `=` and `,` become `%3D` and `%2C`.
.body_includes("net0=virtio%3Drandom%2Cbridge%3Dvmbr0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = AddNICConfig {
bridge: "vmbr0".to_string(),
model: "virtio".to_string(),
macaddr: None,
tag: None,
firewall: None,
};
manager
.add_nic("conn", "pve1", 100, "qemu", config)
.await
.expect("nic should be added");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn edit_nic_preserves_model_and_mac() {
let server = MockServer::start();
let token = "root@pam!edit-nic-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"net0": "virtio=MAC,bridge=vmbr0"}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("net0=virtio%3DMAC%2Cbridge%3Dvmbr1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = EditNICConfig {
bridge: Some("vmbr1".to_string()),
model: None,
tag: None,
firewall: None,
};
manager
.edit_nic("conn", "pve1", 100, "qemu", "net0", config)
.await
.expect("nic should be edited");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn remove_nic_posts_delete() {
let server = MockServer::start();
let token = "root@pam!remove-nic-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("delete=net0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.remove_nic("conn", "pve1", 100, "qemu", "net0")
.await
.expect("nic should be removed");
mock.assert();
}
#[tokio::test]
async fn lxc_vm_types_use_lxc_path() {
let server = MockServer::start();
let token = "root@pam!lxc-token";
let config_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/lxc/201/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"rootfs": "local:vm-201-disk-0,size=8G",
"mp0": "local:vm-201-mp-0,size=4G",
"net0": "name=eth0,bridge=vmbr0,ip=dhcp"
}
})
.to_string(),
);
});
let resize_mock = server.mock(|when, then| {
when.method(PUT)
.path("/api2/json/nodes/pve1/lxc/201/resize")
.body_includes("disk=rootfs")
.body_includes("size=16G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
// Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
// are not returned by get_disks, and network keys without an explicit
// model are still parsed.
let disks = manager
.get_disks("conn", "pve1", 201, "lxc")
.await
.expect("disks should be fetched");
assert!(
disks.is_empty(),
"container disks are not in the scsi/virtio/... key set"
);
let nics = manager
.get_network_interfaces("conn", "pve1", 201, "lxc")
.await
.expect("nics should be fetched");
assert_eq!(nics.len(), 1);
assert_eq!(nics[0].name, "net0");
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
manager
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
.await
.expect("resize should succeed");
config_mock.assert_calls(2);
resize_mock.assert();
}
#[tokio::test]
async fn invalid_vm_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/kvm/100/config");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{}}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.get_disks("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
let config = AddNICConfig {
bridge: "vmbr0".to_string(),
model: "virtio".to_string(),
macaddr: None,
tag: None,
firewall: None,
};
let error = manager
.add_nic("conn", "pve1", 100, "kvm", config)
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.calls(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
+448
View File
@@ -0,0 +1,448 @@
//! Integration tests for the real snapshot, migration, and backup API calls.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction, form encoding, auth header injection, response envelope
//! unwrapping, and mapping into the public serde structs. Token-mode
//! connections resolve the token from the config, so the methods can be called
//! directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{
BackupJobConfig, ConnectionConfig, ConnectionManager, CreateSnapshotConfig, EndpointConfig,
Error,
};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server. `node` pins the storage node.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&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: node.map(str::to_string),
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_snapshots_maps_list() {
let server = MockServer::start();
let token = "root@pam!snap-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"name": "snap1", "description": "before upgrade",
"snaptime": 1700000000, "vmstate": 1, "parent": "current"},
{"name": "snap2", "snaptime": 1700000100, "vmstate": 0}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let snapshots = manager
.get_snapshots("conn", "pve1", 100, "qemu")
.await
.expect("snapshots should be fetched");
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].name, "snap1");
assert_eq!(snapshots[0].description, "before upgrade");
assert_eq!(snapshots[0].snaptime, 1700000000);
assert_eq!(snapshots[0].vmstate, 1);
assert_eq!(snapshots[0].parent.as_deref(), Some("current"));
// Tolerance: the second snapshot omits optional fields.
assert_eq!(snapshots[1].name, "snap2");
assert_eq!(snapshots[1].description, "");
assert_eq!(snapshots[1].parent, None);
mock.assert();
}
#[tokio::test]
async fn create_snapshot_posts_form() {
let server = MockServer::start();
let token = "root@pam!snap-create-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
.body_includes("snapname=snap1")
.body_includes("vmstate=1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.create_snapshot(
"conn",
"pve1",
100,
"qemu",
CreateSnapshotConfig {
name: "snap1".to_string(),
description: Some("before upgrade".to_string()),
vmstate: Some(true),
},
)
.await
.expect("snapshot should be created");
mock.assert();
}
#[tokio::test]
async fn delete_snapshot_deletes_path() {
let server = MockServer::start();
let token = "root@pam!snap-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.delete_snapshot("conn", "pve1", 100, "qemu", "snap1")
.await
.expect("snapshot should be deleted");
mock.assert();
}
#[tokio::test]
async fn rollback_snapshot_posts_rollback() {
let server = MockServer::start();
let token = "root@pam!snap-rollback-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1/rollback");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.rollback_snapshot("conn", "pve1", 100, "qemu", "snap1")
.await
.expect("rollback should succeed");
mock.assert();
}
#[tokio::test]
async fn migrate_vm_posts_migrate() {
let server = MockServer::start();
let token = "root@pam!migrate-token";
let online_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/migrate")
.body_includes("target=pve2")
.body_includes("online=1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let offline_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/migrate")
.body_includes("target=pve2")
.body_includes("online=0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", true)
.await
.expect("online migration should succeed");
manager
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", false)
.await
.expect("offline migration should succeed");
online_mock.assert();
offline_mock.assert();
}
#[tokio::test]
async fn get_backup_jobs_maps_list() {
let server = MockServer::start();
let token = "root@pam!jobs-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/backup")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
"mode": "snapshot", "quiet": 0},
{"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
"all": 0, "enabled": 0, "vmid": "100,101"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let jobs = manager
.get_backup_jobs("conn")
.await
.expect("backup jobs should be fetched");
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "backup-1");
assert_eq!(jobs[0].store, "backup");
assert_eq!(jobs[0].schedule, "0 2 * * *");
assert_eq!(jobs[0].all, 1);
assert_eq!(jobs[0].enabled, 1);
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
assert_eq!(jobs[0].quiet, Some(0));
// Tolerance: the second job omits the optional node/mode fields.
assert_eq!(jobs[1].node, None);
assert_eq!(jobs[1].compress, None);
assert_eq!(jobs[1].mode, None);
assert_eq!(jobs[1].quiet, None);
assert_eq!(jobs[1].vmid.as_deref(), Some("100,101"));
assert_eq!(jobs[1].all, 0);
mock.assert();
}
#[tokio::test]
async fn create_backup_job_posts_form() {
let server = MockServer::start();
let token = "root@pam!job-create-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/cluster/backup")
.body_includes("schedule=0+2+*+*+*")
.body_includes("storage=backup")
.body_includes("mode=snapshot")
.body_includes("compress=zstd")
.body_includes("all=1")
.body_includes("enabled=1")
.body_includes("vmid=100%2C101")
.body_includes("node=pve1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.create_backup_job(
"conn",
BackupJobConfig {
id: None,
storage: "backup".to_string(),
schedule: "0 2 * * *".to_string(),
mode: "snapshot".to_string(),
compression: "zstd".to_string(),
all: true,
vmid: Some("100,101".to_string()),
enabled: true,
node: Some("pve1".to_string()),
},
)
.await
.expect("backup job should be created");
mock.assert();
}
#[tokio::test]
async fn delete_backup_job_deletes_path() {
let server = MockServer::start();
let token = "root@pam!job-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE).path("/api2/json/cluster/backup/job-id");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.delete_backup_job("conn", "job-id")
.await
.expect("backup job should be deleted");
mock.assert();
}
#[tokio::test]
async fn get_backups_filters_and_maps() {
let server = MockServer::start();
let token = "root@pam!backups-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content")
.query_param("content", "backup");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"volid": "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
"backup-type": "qemu", "backup-id": "100",
"backup-time": 1700000000, "storage": "local",
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"},
{"volid": "local:iso/debian-12.iso", "content": "iso",
"ctime": 1700000002},
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
"ctime": 1700000003}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let backups = manager
.get_backups("conn", Some("local"))
.await
.expect("backups should be fetched");
assert_eq!(backups.len(), 1);
assert_eq!(
backups[0].volid,
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
);
assert_eq!(
backups[0].backupid,
"vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
);
assert_eq!(backups[0].backup_type, "qemu");
assert_eq!(backups[0].backup_id, "100");
assert_eq!(backups[0].backup_time, 1700000000);
assert_eq!(backups[0].storage, "local");
assert_eq!(backups[0].size, 1073741824u64);
assert_eq!(backups[0].ctime, 1700000001);
mock.assert();
}
#[tokio::test]
async fn get_backups_defaults_to_local_storage() {
let server = MockServer::start();
let token = "root@pam!backups-default-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content")
.query_param("content", "backup");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let backups = manager
.get_backups("conn", None)
.await
.expect("backups should be fetched");
assert!(backups.is_empty());
mock.assert();
}
#[tokio::test]
async fn delete_backup_url_encodes_volid() {
let server = MockServer::start();
let token = "root@pam!backup-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE).path(
"/api2/json/nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
);
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
manager
.delete_backup(
"conn",
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
)
.await
.expect("backup should be deleted");
mock.assert();
}
#[tokio::test]
async fn invalid_vm_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/kvm/100/snapshot");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.get_snapshots("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
let error = manager
.migrate_vm("conn", "pve1", 100, "kvm", "pve2", true)
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.calls(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
+467
View File
@@ -0,0 +1,467 @@
//! Integration tests for the connect-time node discovery, same-cluster
//! merging, and the connection status reporting flow.
//!
//! `connect` now authenticates, resets failover state, and discovers the
//! cluster's nodes before either folding the connection into an existing
//! same-cluster connection or marking it connected. These tests cover the
//! discovered-node persistence, the merge of a second node's connection into
//! the first, the status reporting (including failover and disconnected), and
//! the all-endpoints-down case where the connect reports `"failed"` instead of
//! erroring.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
const TOKEN: &str = "root@pam!cluster-token";
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
/// are included.
fn node_json(name: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"node": name,
"status": status,
"cpu": 0.0,
"maxcpu": 8,
"mem": 0,
"maxmem": 34359738368u64,
"disk": 0,
"maxdisk": 536870912000u64,
"uptime": 0,
"level": "",
"id": format!("node/{}", name),
"type": "node"
})
}
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
/// the given node entries.
fn cluster_status_body(
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> String {
let mut entries = Vec::with_capacity(nodes.len() + 1);
entries.push(serde_json::json!({
"type": "cluster",
"id": cluster_id,
"name": cluster_name,
"nodes": nodes.len()
}));
entries.extend_from_slice(nodes);
serde_json::json!({ "data": entries }).to_string()
}
fn stub_version<'a>(server: &'a MockServer) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
})
}
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": nodes }).to_string());
})
}
fn stub_cluster_status<'a>(
server: &'a MockServer,
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(cluster_status_body(cluster_id, cluster_name, nodes));
})
}
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
/// ephemeral port, its address is captured, and the listener is dropped so any
/// subsequent connection attempt is refused.
fn closed_port_url() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("address should be available");
drop(listener);
format!("http://{}", addr)
}
fn fallback_config(url: &str) -> EndpointConfig {
EndpointConfig {
url: url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
}
}
/// Builds a token-mode connection config that skips certificate verification
/// (the mock servers speak plain HTTP).
fn token_config(id: &str, url: &str, fallbacks: Vec<EndpointConfig>) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
},
fallbacks,
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,
}
}
async fn add_connection(
manager: &mut ConnectionManager,
path: &std::path::Path,
config: ConnectionConfig,
) {
manager
.add_connection(config, path)
.await
.expect("connection should be added");
}
#[tokio::test]
async fn connect_discovers_and_persists_nodes() {
let server = MockServer::start();
let port = server.port();
stub_version(&server);
stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": "10.0.0.6"}),
],
);
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &server.base_url(), vec![]),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("connect should succeed");
assert_eq!(result.status, "connected");
assert_eq!(result.merged_into, None);
assert_eq!(result.connection_id, "conn");
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.nodes.len(), 2);
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
// The discovered nodes and cluster identity are persisted.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let conn_json = &json["connections"][0];
assert_eq!(conn_json["clusterId"], "cluster/lab");
assert_eq!(
conn_json["nodes"]
.as_array()
.expect("nodes should be present")
.len(),
2
);
assert_eq!(
conn_json["nodes"][0]["url"],
format!("http://10.0.0.5:{}", port),
"the primary node's URL must be persisted"
);
assert_eq!(conn_json["status"], "connected");
}
#[tokio::test]
async fn connect_second_node_merges_into_existing() {
let server_a = MockServer::start();
let server_b = MockServer::start();
// Both servers report the same cluster identity, as two nodes of one
// cluster would.
for (server, ip1, ip2) in [
(&server_a, "10.0.0.5", "10.0.0.6"),
(&server_b, "10.0.0.7", "10.0.0.8"),
] {
stub_version(server);
stub_nodes(
server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
stub_cluster_status(
server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": ip1}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": ip2}),
],
);
}
let vms_mock = server_a.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
"status": "running", "node": "pve1"}]
})
.to_string(),
);
});
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn-a", &server_a.base_url(), vec![]),
)
.await;
add_connection(
&mut manager,
&path,
token_config("conn-b", &server_b.base_url(), vec![]),
)
.await;
let first = manager
.connect("conn-a", &path)
.await
.expect("first connect should succeed");
assert_eq!(first.status, "connected");
assert_eq!(first.merged_into, None);
let second = manager
.connect("conn-b", &path)
.await
.expect("second connect should succeed");
assert_eq!(second.status, "connected");
assert_eq!(second.connection_id, "conn-a");
assert_eq!(second.merged_into.as_deref(), Some("conn-a"));
// The merged connection is gone and conn-a carries B's endpoint.
assert!(
matches!(
manager.connection_config("conn-b"),
Err(Error::ConnectionNotFound(ref id)) if id == "conn-b"
),
"conn-b must be removed after merging"
);
let config_a = manager
.connection_config("conn-a")
.expect("config should be readable");
assert!(
config_a
.fallbacks
.iter()
.any(|endpoint| endpoint.url == server_b.base_url()),
"conn-a must have B's URL as a fallback"
);
assert_eq!(
config_a.nodes.len(),
4,
"the node lists of both servers must be merged"
);
// Only the surviving connection is persisted.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let ids: Vec<&str> = json["connections"]
.as_array()
.expect("connections should be an array")
.iter()
.filter_map(|c| c["id"].as_str())
.collect();
assert_eq!(ids, vec!["conn-a"]);
assert_eq!(
manager
.runtime_status("conn-a")
.expect("status should be readable"),
"connected"
);
// A subsequent data request on the merged connection is served by A.
let vms = manager
.get_vms("conn-a")
.await
.expect("get_vms should succeed via A");
assert_eq!(vms.len(), 1);
vms_mock.assert();
}
#[tokio::test]
async fn status_info_reports_failover() {
let fallback = MockServer::start();
stub_version(&fallback);
stub_nodes(&fallback, &[node_json("pve1", "online")]);
stub_cluster_status(
&fallback,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
],
);
let vms_mock = fallback.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
"status": "running", "node": "pve1"}]
})
.to_string(),
);
});
let primary_url = closed_port_url();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config(
"conn",
&primary_url,
vec![fallback_config(&fallback.base_url())],
),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("connect should succeed via the fallback");
assert_eq!(result.status, "connected");
let vms = manager
.get_vms("conn")
.await
.expect("get_vms should succeed via the fallback");
assert_eq!(vms.len(), 1);
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"failover"
);
let info = manager
.status_info("conn")
.await
.expect("status info should be readable");
assert_eq!(info.status, "failover");
assert_eq!(info.current_endpoint_url, fallback.base_url());
assert_eq!(info.primary_url, primary_url);
vms_mock.assert();
}
#[tokio::test]
async fn status_info_when_disconnected() {
let server = MockServer::start();
let version_mock = stub_version(&server);
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &server.base_url(), vec![]),
)
.await;
let info = manager
.status_info("conn")
.await
.expect("status info should be readable");
assert_eq!(info.status, "disconnected");
assert_eq!(info.primary_url, server.base_url());
assert_eq!(info.current_endpoint_url, server.base_url());
assert!(info.nodes.is_empty());
assert_eq!(
version_mock.hits(),
0,
"a disconnected connection must not hit the network"
);
}
#[tokio::test]
async fn connect_all_endpoints_down_returns_failed_result() {
let primary_url = closed_port_url();
let fallback_url = closed_port_url();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &primary_url, vec![fallback_config(&fallback_url)]),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("an unreachable cluster must be reported, not errored");
assert_eq!(result.status, "failed");
assert_eq!(result.merged_into, None);
assert_eq!(result.connection_id, "conn");
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.status, "failed");
// The failed status is persisted so the connection stays tracked.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["connections"][0]["status"], "failed");
}
+289
View File
@@ -0,0 +1,289 @@
//! Integration tests for cluster node auto-discovery.
//!
//! `discover_nodes` combines `/nodes` (the node list and statuses) with
//! `/cluster/status` (cluster IPs, local flags, and cluster identity) and
//! derives an endpoint URL per node from the connection's primary URL. These
//! tests cover the URL derivation rules, primary-node marking, the config
//! mutation, and the persist -> load round-trip.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig};
const TOKEN: &str = "root@pam!discovery-token";
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
/// are included.
fn node_json(name: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"node": name,
"status": status,
"cpu": 0.0,
"maxcpu": 8,
"mem": 0,
"maxmem": 34359738368u64,
"disk": 0,
"maxdisk": 536870912000u64,
"uptime": 0,
"level": "",
"id": format!("node/{}", name),
"type": "node"
})
}
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint is the mock server. `node` pins `primary.node`.
async fn setup_manager(
server: &MockServer,
node: Option<&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: server.base_url(),
node: node.map(str::to_string),
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
/// Stubs `GET /api2/json/nodes` returning `nodes` (entries from `node_json`).
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": nodes }).to_string());
})
}
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
/// the given node entries.
fn cluster_status_body(
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> String {
let mut entries = Vec::with_capacity(nodes.len() + 1);
entries.push(serde_json::json!({
"type": "cluster",
"id": cluster_id,
"name": cluster_name,
"nodes": nodes.len()
}));
entries.extend_from_slice(nodes);
serde_json::json!({ "data": entries }).to_string()
}
/// Stubs `GET /api2/json/cluster/status` returning the cluster entry plus the
/// given node entries.
fn stub_cluster_status<'a>(
server: &'a MockServer,
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(cluster_status_body(cluster_id, cluster_name, nodes));
})
}
#[test]
fn derive_node_url_keeps_scheme_and_port() {
assert_eq!(
derive_node_url("https://10.0.0.5:8006", Some("10.0.0.6"), "pve1"),
"https://10.0.0.6:8006"
);
// An empty IP falls back to the node name.
assert_eq!(
derive_node_url("http://pve.lan:8006", Some(""), "pve.lan"),
"http://pve.lan:8006"
);
// A missing port defaults to pveproxy's 8006 for both schemes.
assert_eq!(
derive_node_url("https://pve.lan", Some("10.0.0.7"), "pve1"),
"https://10.0.0.7:8006"
);
// An unparseable primary URL falls back to a best-effort https URL.
assert_eq!(
derive_node_url("not a url", Some("10.0.0.8"), "pve1"),
"https://10.0.0.8:8006"
);
}
#[tokio::test]
async fn discover_nodes_builds_and_marks_primary() {
let server = MockServer::start();
let port = server.port();
let nodes_mock = stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": "10.0.0.6"}),
],
);
let (mut manager, _dir) = setup_manager(&server, Some("pve1")).await;
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
assert_eq!(discovered.len(), 2);
assert_eq!(
discovered[0].name, "pve1",
"the primary node must sort first"
);
assert!(discovered[0].is_primary);
assert!(discovered[0].local);
assert_eq!(discovered[0].status, "online");
assert_eq!(
discovered[0].url,
format!("http://10.0.0.5:{}", port),
"pve1's URL keeps the scheme and port but uses the cluster IP"
);
assert_eq!(discovered[1].name, "pve2");
assert!(!discovered[1].is_primary);
assert!(!discovered[1].local);
assert_eq!(discovered[1].url, format!("http://10.0.0.6:{}", port));
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.cluster_name.as_deref(), Some("lab"));
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
assert_eq!(
config.nodes, discovered,
"the discovered list must be stored"
);
nodes_mock.assert();
cluster_mock.assert();
}
#[tokio::test]
async fn discover_nodes_persists_and_round_trips() {
let server = MockServer::start();
let nodes_mock = stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "offline")],
);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 0, "local": 0, "ip": "10.0.0.6"}),
],
);
let (mut manager, dir) = setup_manager(&server, Some("pve1")).await;
let path = dir.path().join("connections.json");
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
// discover_nodes stores on the config but does not persist by itself; any
// later persist (here via set_active_connection) writes the new fields.
manager
.set_active_connection("conn".to_string(), &path)
.await
.expect("active connection should be set");
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
let loaded = &result.connections[0];
assert_eq!(loaded.nodes, discovered, "discovered nodes must round-trip");
assert_eq!(loaded.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(loaded.cluster_name.as_deref(), Some("lab"));
nodes_mock.assert();
cluster_mock.assert();
}
#[tokio::test]
async fn discover_nodes_sets_primary_node_from_url_match() {
let server = MockServer::start();
// The mock server's host is used as the node's cluster IP so the derived
// URL equals the primary URL.
let host = server.host();
let nodes_mock = stub_nodes(&server, &[node_json("pve1", "online")]);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": host}),
],
);
let (mut manager, _dir) = setup_manager(&server, None).await;
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
assert_eq!(discovered.len(), 1);
assert_eq!(
discovered[0].url,
server.base_url(),
"the derived URL must equal the primary URL"
);
assert!(
discovered[0].is_primary,
"a node whose URL matches the primary URL must be marked primary"
);
// With primary.node unset, discovery pins it to the primary node.
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
nodes_mock.assert();
cluster_mock.assert();
}
+202
View File
@@ -0,0 +1,202 @@
//! Integration tests for automatic failover across cluster endpoints.
//!
//! Transport-level failures (connection refused, timeouts, DNS resolution)
//! must be distinguishable from real authentication failures so the request
//! core can rotate to the next configured endpoint. These tests cover the
//! error mapping in `api_request` and the rotation logic in
//! `Connection::request`.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{
api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error,
};
use reqwest::Client;
use reqwest::Method as RMethod;
const TOKEN: &str = "root@pam!failover-token";
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
/// ephemeral port, its address is captured, and the listener is dropped so
/// any subsequent connection attempt is refused.
fn closed_port_url() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("address should be available");
drop(listener);
format!("http://{}", addr)
}
fn token_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Token,
token: Some(TOKEN.to_string()),
ticket: None,
csrf_token: None,
}
}
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint is `primary_url` and whose fallbacks are `fallback_urls`.
async fn setup_manager_with_fallbacks(
primary_url: &str,
fallback_urls: &[String],
) -> (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: primary_url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
},
fallbacks: fallback_urls
.iter()
.map(|url| EndpointConfig {
url: url.clone(),
node: None,
token: Some(TOKEN.to_string()),
})
.collect(),
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,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
/// Stubs `GET /api2/json/cluster/resources?type=vm` on `server`.
fn stub_vm_resources<'a>(server: &'a MockServer, status: u16, body: &str) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(status)
.header("content-type", "application/json")
.body(body);
})
}
#[tokio::test]
async fn transport_error_maps_to_connection_failed() {
let url = closed_port_url();
let error = api_request(
&Client::new(),
&url,
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("connection refused must fail");
assert!(
matches!(error, Error::ConnectionFailed(_)),
"expected ConnectionFailed, got: {}",
error
);
}
#[tokio::test]
async fn request_rotates_to_fallback_when_primary_down() {
let fallback = MockServer::start();
let fallback_mock = stub_vm_resources(
&fallback,
200,
&serde_json::json!({
"data": [
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
"node": "pve1"}
]
})
.to_string(),
);
let primary_url = closed_port_url();
let (manager, _dir) = setup_manager_with_fallbacks(&primary_url, &[fallback.base_url()]).await;
let vms = manager
.get_vms("conn")
.await
.expect("request should succeed via the fallback endpoint");
assert_eq!(vms.len(), 1);
assert_eq!(vms[0].vmid, 100);
assert_eq!(vms[0].name.as_deref(), Some("web01"));
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"failover"
);
fallback_mock.assert();
}
#[tokio::test]
async fn non_transport_error_does_not_rotate() {
let primary = MockServer::start();
let fallback = MockServer::start();
let primary_mock = stub_vm_resources(&primary, 500, r#"{"message":"boom"}"#);
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
let (manager, _dir) =
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
let error = manager
.get_vms("conn")
.await
.expect_err("a 500 response must surface as an ApiError");
assert!(
matches!(error, Error::ApiError(ref message) if message == "boom"),
"expected ApiError with message 'boom', got: {}",
error
);
primary_mock.assert();
assert_eq!(
fallback_mock.hits(),
0,
"a non-transport error must not trigger failover"
);
}
#[tokio::test]
async fn request_succeeds_on_primary_again_when_it_returns() {
let primary = MockServer::start();
let fallback = MockServer::start();
let primary_mock = stub_vm_resources(&primary, 200, r#"{"data":[]}"#);
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
let (manager, _dir) =
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
let vms = manager
.get_vms("conn")
.await
.expect("request should succeed on the primary endpoint");
assert!(vms.is_empty());
primary_mock.assert();
assert_eq!(fallback_mock.hits(), 0);
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"connected"
);
}
+489
View File
@@ -0,0 +1,489 @@
//! Integration tests for connection persistence and the connect/disconnect
//! flow.
//!
//! Connection configs are persisted to a JSON file with secrets (API tokens)
//! stripped, so they never round-trip through the file; after a restart the
//! request layer falls back to the OS keyring.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a token-mode connection config with a real token set.
fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: None,
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
}
}
#[tokio::test]
async fn persisted_configs_round_trip_without_secrets() {
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-1".to_string(),
name: "Proxmox".to_string(),
primary: EndpointConfig {
url: "https://pve.local:8006".to_string(),
node: None,
token: Some("root@pam!primary-secret".to_string()),
},
fallbacks: vec![EndpointConfig {
url: "https://backup.local:8006".to_string(),
node: None,
token: Some("root@pam!backup-secret".to_string()),
}],
cert_fingerprint: None,
trusted: false,
accept_untrusted: false,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
// The persisted file must contain the connection but never its secrets.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["activeConnectionId"], serde_json::Value::Null);
let conn = &json["connections"][0];
assert_eq!(conn["id"], "conn-1");
assert_eq!(conn["name"], "Proxmox");
assert!(
conn["primary"].get("token").is_none(),
"primary token must not be serialized"
);
assert!(
conn["fallbacks"][0].get("token").is_none(),
"fallback token must not be serialized"
);
assert!(
conn.get("certFingerprint").is_none(),
"absent certFingerprint must not be serialized"
);
// Reloading from disk rebuilds the connection; the token does not
// round-trip and every connection starts disconnected.
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
let loaded = &result.connections[0];
assert_eq!(loaded.id, "conn-1");
assert_eq!(loaded.name, "Proxmox");
assert!(
loaded.primary.token.is_none(),
"token must not round-trip through the file"
);
assert_eq!(loaded.status, "disconnected");
}
#[tokio::test]
async fn load_connections_with_missing_file_returns_empty() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("missing").join("connections.json");
let mut manager = ConnectionManager::new();
let result = manager
.load_connections(&path)
.await
.expect("missing file is not an error");
assert!(result.active_connection_id.is_none());
assert!(result.connections.is_empty());
}
#[tokio::test]
async fn remove_connection_persists_and_keeps_active() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", false),
&path,
)
.await
.expect("connection 1 should be added");
manager
.add_connection(
token_config("conn-2", "https://two.local:8006", "tok-2", false),
&path,
)
.await
.expect("connection 2 should be added");
manager
.set_active_connection("conn-2".to_string(), &path)
.await
.expect("active connection should be set");
manager
.remove_connection("conn-1", &path)
.await
.expect("connection 1 should be removed");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let ids: Vec<&str> = json["connections"]
.as_array()
.expect("connections should be an array")
.iter()
.filter_map(|c| c["id"].as_str())
.collect();
assert_eq!(
ids,
vec!["conn-2"],
"removed connection must not be persisted"
);
assert_eq!(
json["activeConnectionId"], "conn-2",
"removing a non-active connection must keep the active id"
);
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
assert_eq!(result.connections[0].id, "conn-2");
assert_eq!(result.active_connection_id.as_deref(), Some("conn-2"));
}
#[tokio::test]
async fn update_connection_preserves_cert_settings_and_replaces_config() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", true),
&path,
)
.await
.expect("connection should be added");
manager
.trust_certificate("conn-1", "AB:CD:EF", &path)
.await
.expect("certificate should be pinned");
// Update the name while omitting the certificate fields; the pinned
// fingerprint and trust settings must survive the update.
let updated = ConnectionConfig {
id: "conn-1".to_string(),
name: "Renamed".to_string(),
primary: EndpointConfig {
url: "https://one.local:8006".to_string(),
node: None,
token: None,
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: false,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.update_connection(updated, &path)
.await
.expect("connection should be updated");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let conn = &json["connections"][0];
assert_eq!(conn["id"], "conn-1");
assert_eq!(conn["name"], "Renamed");
assert_eq!(
conn["certFingerprint"], "AB:CD:EF",
"cert_fingerprint must be preserved when omitted"
);
assert_eq!(
conn["trusted"], true,
"trusted must be preserved when omitted"
);
assert_eq!(
conn["acceptUntrusted"], true,
"accept_untrusted must be preserved when omitted"
);
// The updated config is what the reloaded manager serves.
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
assert_eq!(result.connections[0].name, "Renamed");
}
#[tokio::test]
async fn update_connection_unknown_id_fails() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let error = manager
.update_connection(
token_config("missing", "https://nope.local:8006", "tok", false),
&path,
)
.await
.expect_err("updating an unknown connection must fail");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "missing"),
"expected ConnectionNotFound for the missing id, got: {}",
error
);
}
#[tokio::test]
async fn remove_active_connection_clears_active_id() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", false),
&path,
)
.await
.expect("connection should be added");
manager
.set_active_connection("conn-1".to_string(), &path)
.await
.expect("active connection should be set");
manager
.remove_connection("conn-1", &path)
.await
.expect("connection should be removed");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert!(
json["activeConnectionId"].is_null(),
"removing the active connection must clear the active id"
);
}
#[tokio::test]
async fn connect_token_mode_validates_version_endpoint() {
let server = MockServer::start();
let token = "root@pam!test-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2","release":"8.2.4","repoid":"abc"}}"#);
});
// Connect also discovers the cluster's nodes, so the discovery endpoints
// are stubbed (empty cluster: no node entries, no cluster identity).
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
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();
// `accept_untrusted` skips the TLS capture, which is required here because
// the mock server speaks plain HTTP.
manager
.add_connection(
token_config("conn-token", &server.base_url(), token, true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-token", &path)
.await
.expect("connect should succeed against the mock server");
assert_eq!(result.status, "connected");
assert_eq!(result.merged_into, None);
mock.assert();
// The status update is persisted alongside the connection.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(
json["connections"][0]["status"], "connected",
"connect must persist the connected status"
);
}
#[tokio::test]
async fn connect_without_cert_pin_and_no_escape_hatch_fails() {
let server = MockServer::start();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-untrusted", &server.base_url(), "tok", false),
&path,
)
.await
.expect("connection should be added");
// The guard rejects the connect before any TLS is attempted (the guard is
// hit regardless of the scheme, so the http:// mock URL is fine here).
let error = manager
.connect("conn-untrusted", &path)
.await
.expect_err("connect must fail without a pin or escape hatch");
assert!(
matches!(error, Error::CertificateError(ref message) if message.contains("not been trusted")),
"expected CertificateError about the untrusted certificate, got: {}",
error
);
}
#[tokio::test]
async fn connect_accept_untrusted_escape_hatch_reaches_version() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
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();
manager
.add_connection(
token_config("conn-escape", &server.base_url(), "tok", true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-escape", &path)
.await
.expect("the escape hatch must skip certificate verification");
assert_eq!(result.status, "connected");
mock.assert();
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["connections"][0]["status"], "connected");
}
#[tokio::test]
async fn disconnect_clears_session_and_status() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
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();
manager
.add_connection(
token_config("conn-disc", &server.base_url(), "tok", true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-disc", &path)
.await
.expect("connect should succeed");
assert_eq!(result.status, "connected");
manager
.disconnect("conn-disc")
.await
.expect("disconnect should succeed");
// Reconnecting after a disconnect works, so the credentials used to
// authenticate (the in-config token) must still be available. This
// triggers a second request to the version endpoint.
let result = manager
.connect("conn-disc", &path)
.await
.expect("reconnect after disconnect should succeed");
assert_eq!(result.status, "connected");
mock.assert_hits(2);
}
+186
View File
@@ -0,0 +1,186 @@
//! Integration tests for the application-layer TLS certificate capture and
//! TOFU (Trust On First Use) pinning helpers.
//!
//! Each test spawns a local TLS server with a freshly generated self-signed
//! certificate and exercises the capture/verify helpers against it.
use proxmox_desktop::tls::{
capture_fingerprint, fetch_certificate_info, verify_pin, verify_server_certificate,
};
use proxmox_desktop::Error;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio_rustls::TlsAcceptor;
/// A plausible but wrong fingerprint (SHA-256 size, hex-uppercase, colon-separated).
const WRONG_PIN: &str = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
/// Computes the expected `AA:BB:CC:...` uppercase fingerprint from raw DER.
fn fingerprint_of(der: &[u8]) -> String {
let digest = Sha256::digest(der);
let hex = hex::encode_upper(digest);
hex.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
.collect::<Vec<_>>()
.join(":")
}
/// Generates a self-signed certificate and key for `127.0.0.1`.
fn generate_self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()])
.expect("self-signed cert generation should succeed");
let cert_der = certified.cert.der().clone();
let key_der =
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()));
(cert_der, key_der)
}
/// Spawns a TLS server on an ephemeral port serving `cert_der`/`key_der` and
/// returns the `https://` URL and the expected certificate fingerprint.
async fn spawn_tls_server(
cert_der: CertificateDer<'static>,
key_der: PrivateKeyDer<'static>,
) -> (String, String) {
// rustls cannot auto-select a process-level CryptoProvider when the
// dependency graph enables both `ring` (reqwest/tokio-rustls) and the
// rustls default `aws-lc-rs`. The production capture helpers install ring
// explicitly; the spawned server below must do the same or it races with
// (and loses to) the client's install before building its config.
let _ = rustls::crypto::ring::default_provider().install_default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind should succeed");
let address = listener.local_addr().expect("local addr should exist");
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der.clone()], key_der)
.expect("server config should build");
let acceptor = Arc::new(TlsAcceptor::from(Arc::new(config)));
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(accepted) => accepted,
Err(_) => continue,
};
let acceptor = Arc::clone(&acceptor);
tokio::spawn(async move {
// Complete the handshake, then drop the connection. The client
// captures the certificate as soon as the handshake finishes.
let _ = acceptor.accept(stream).await;
});
}
});
let expected = fingerprint_of(cert_der.as_ref());
(format!("https://{}", address), expected)
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_certificate_info_returns_real_certificate_data() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let info = fetch_certificate_info(&url)
.await
.expect("certificate info should be fetched");
assert_eq!(
info.fingerprint, expected,
"fingerprint must match the SHA-256 of the served certificate"
);
assert!(info.self_signed, "generated certificate is self-signed");
}
#[tokio::test(flavor = "multi_thread")]
async fn capture_fingerprint_matches_served_certificate() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = capture_fingerprint(&url)
.await
.expect("fingerprint should be captured");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_with_correct_pin_succeeds() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, Some(&expected), false)
.await
.expect("matching pin should verify");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_with_wrong_pin_fails() {
let (cert_der, key_der) = generate_self_signed();
let (url, _expected) = spawn_tls_server(cert_der, key_der).await;
let error = verify_server_certificate(&url, Some(WRONG_PIN), false)
.await
.expect_err("mismatched pin should fail");
assert!(
matches!(error, Error::CertificateError(ref message) if message.contains("fingerprint")),
"expected CertificateError, got: {}",
error
);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_accepts_untrusted_on_mismatch() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, Some(WRONG_PIN), true)
.await
.expect("accept_untrusted should bypass the pin mismatch");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_without_pin_returns_fingerprint() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, None, false)
.await
.expect("first use should return the fingerprint");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_certificate_info_rejects_non_https_urls() {
let error = fetch_certificate_info("http://127.0.0.1:8006")
.await
.expect_err("http URL must be rejected");
assert!(
matches!(error, Error::InvalidUrl(_)),
"expected InvalidUrl, got: {}",
error
);
}
#[test]
fn verify_pin_is_case_insensitive_and_separator_agnostic() {
let canonical = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90";
assert!(verify_pin(canonical, canonical));
assert!(verify_pin(canonical, &canonical.to_lowercase()));
assert!(verify_pin(&canonical.replace(':', ""), canonical));
assert!(verify_pin(canonical, &canonical.replace(':', " ")));
assert!(!verify_pin(canonical, WRONG_PIN));
}
+149 -10
View File
@@ -4,6 +4,8 @@ import { Dashboard } from '@/components/layout/Dashboard'
import { ConnectionDialog } from '@/components/connections/ConnectionDialog' import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
import { VMList } from '@/components/vms/VMList' import { VMList } from '@/components/vms/VMList'
import { VMDetail } from '@/components/vms/VMDetail' import { VMDetail } from '@/components/vms/VMDetail'
import { ContainerList } from '@/components/vms/ContainerList'
import { NodesPage } from '@/components/nodes/NodesPage'
import { TaskList } from '@/components/tasks/TaskList' import { TaskList } from '@/components/tasks/TaskList'
import { BackupList } from '@/components/backups/BackupList' import { BackupList } from '@/components/backups/BackupList'
import { CommandPalette } from '@/components/command/CommandPalette' import { CommandPalette } from '@/components/command/CommandPalette'
@@ -11,11 +13,13 @@ import { StorageOverview } from '@/components/storage/StorageOverview'
import { StorageDetail } from '@/components/storage/StorageDetail' import { StorageDetail } from '@/components/storage/StorageDetail'
import { SettingsPage } from '@/components/settings/SettingsPage' import { SettingsPage } from '@/components/settings/SettingsPage'
import { ErrorBoundary } from '@/components/ErrorBoundary' import { ErrorBoundary } from '@/components/ErrorBoundary'
import { ToastProvider } from '@/components/ui/toast' import { ToastProvider, useToast } from '@/components/ui/toast'
import { Server, AlertTriangle } from 'lucide-react'
import { useConnectionStore } from '@/stores/connectionStore' import { useConnectionStore } from '@/stores/connectionStore'
import { useUIStore } from '@/stores/uiStore' import { useUIStore } from '@/stores/uiStore'
import { useWebSocket } from '@/hooks/useWebSocket' import { useWebSocket } from '@/hooks/useWebSocket'
import { useEffect, useState } from 'react' import { isTauri, loadConnections, connectToServer, getConnectionStatus, updateTrayMenu } from '@/lib/tauri'
import { useEffect, useRef, useState } from 'react'
import type { ProxmoxVM } from '@/types/proxmox' import type { ProxmoxVM } from '@/types/proxmox'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -31,6 +35,9 @@ type View =
| { type: 'dashboard' } | { type: 'dashboard' }
| { type: 'vms' } | { type: 'vms' }
| { type: 'vm-detail'; vm: ProxmoxVM } | { type: 'vm-detail'; vm: ProxmoxVM }
| { type: 'nodes' }
| { type: 'node-detail'; nodeName: string }
| { type: 'containers' }
| { type: 'tasks' } | { type: 'tasks' }
| { type: 'backups' } | { type: 'backups' }
| { type: 'storage' } | { type: 'storage' }
@@ -39,17 +46,115 @@ type View =
function AppContent() { function AppContent() {
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId) const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
const connections = useConnectionStore((s) => s.connections)
const hydrate = useConnectionStore((s) => s.hydrate)
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
const updateConnection = useConnectionStore((s) => s.updateConnection)
const removeConnection = useConnectionStore((s) => s.removeConnection)
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
const { addToast } = useToast()
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false) const [connectionDialogOpen, setConnectionDialogOpen] = useState(false)
const [view, setView] = useState<View>({ type: 'dashboard' }) const [view, setView] = useState<View>({ type: 'dashboard' })
const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen) const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen)
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen) const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen)
// In browser mock mode there is nothing to load, so the app is ready
// immediately. In Tauri mode the persisted connections load on mount.
const [connectionsLoaded, setConnectionsLoaded] = useState(!isTauri())
const bootRan = useRef(false)
// Auto-open login dialog when no active connection // Startup: load persisted connections and auto-reconnect the active one
useEffect(() => { useEffect(() => {
if (!activeConnectionId && !connectionDialogOpen) { if (bootRan.current) return
bootRan.current = true
if (!isTauri()) return
let cancelled = false
const boot = async () => {
try {
const { connections: loaded, activeConnectionId } = await loadConnections()
if (cancelled) return
hydrate(loaded, activeConnectionId)
if (activeConnectionId && loaded.some((c) => c.id === activeConnectionId)) {
setActiveConnection(activeConnectionId)
setConnectionStatus(activeConnectionId, 'connecting')
try {
const result = await connectToServer(activeConnectionId)
if (cancelled) return
if (result.mergedInto && result.mergedInto !== activeConnectionId) {
// This connection belongs to a cluster we already have; the
// backend folded it into the surviving connection.
removeConnection(activeConnectionId)
setActiveConnection(result.mergedInto)
setConnectionStatus(result.mergedInto, result.status)
setAuthStatus('authenticated')
addToast('Already connected to this cluster — added as a failover endpoint.', 'success')
} else {
setConnectionStatus(result.connectionId, result.status)
}
} catch (err) {
if (!cancelled) {
setConnectionStatus(activeConnectionId, 'failed')
console.error('[App] Auto-reconnect failed:', err)
}
}
}
} catch (err) {
console.error('[App] Failed to load connections:', err)
} finally {
if (!cancelled) setConnectionsLoaded(true)
}
}
boot()
return () => {
cancelled = true
}
}, [hydrate, setActiveConnection, setConnectionStatus, setAuthStatus, removeConnection, addToast])
// Keep the system tray menu in sync with the connection list (no-op in browser mode)
useEffect(() => {
updateTrayMenu(
connections.map((c) => ({ id: c.id, name: c.name, status: c.status })),
)
}, [connections])
// Poll the backend connection status while a connection is active so the
// sidebar node list and failover state stay in sync with the cluster.
useEffect(() => {
if (!isTauri() || !activeConnectionId) return
let cancelled = false
const poll = async () => {
try {
const info = await getConnectionStatus(activeConnectionId)
if (cancelled) return
setConnectionStatus(activeConnectionId, info.status)
updateConnection(activeConnectionId, {
nodes: info.nodes,
currentEndpointUrl: info.currentEndpointUrl,
})
} catch {
// Transient failures are ignored; the next tick keeps polling.
}
}
poll()
const interval = setInterval(poll, 10_000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [activeConnectionId, setConnectionStatus, updateConnection])
// Auto-open login dialog only when there are genuinely zero connections
useEffect(() => {
if (connectionsLoaded && connections.length === 0 && !connectionDialogOpen) {
setConnectionDialogOpen(true) setConnectionDialogOpen(true)
} }
}, [activeConnectionId, connectionDialogOpen]) }, [connectionsLoaded, connections.length, connectionDialogOpen])
// WebSocket integration – connects when a connection is active // WebSocket integration – connects when a connection is active
useWebSocket(activeConnectionId) useWebSocket(activeConnectionId)
@@ -77,10 +182,15 @@ function AppContent() {
if (!activeConnectionId) { if (!activeConnectionId) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center p-6">
<div className="text-center space-y-4"> <div className="flex flex-col items-center text-center">
<h2 className="text-2xl font-semibold">ProxmoxDesktop</h2> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
<p className="text-muted-foreground"> <Server className="h-5 w-5 text-primary" />
</div>
<h2 className="text-lg font-semibold tracking-tight">
No connection selected
</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Connect to a Proxmox server to get started Connect to a Proxmox server to get started
</p> </p>
</div> </div>
@@ -115,6 +225,22 @@ function AppContent() {
onBack={() => handleNavigate({ type: 'vms' })} onBack={() => handleNavigate({ type: 'vms' })}
/> />
) )
case 'nodes':
return <NodesPage connectionId={activeConnectionId} />
case 'node-detail':
return (
<NodesPage
connectionId={activeConnectionId}
initialNodeName={view.nodeName}
/>
)
case 'containers':
return (
<ContainerList
connectionId={activeConnectionId}
onContainerClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
/>
)
case 'tasks': case 'tasks':
return <TaskList connectionId={activeConnectionId} /> return <TaskList connectionId={activeConnectionId} />
case 'backups': case 'backups':
@@ -146,8 +272,20 @@ function AppContent() {
} }
} }
const activeConnection = connections.find((c) => c.id === activeConnectionId)
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen flex-col bg-background">
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
<div className="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<p className="text-xs font-medium text-warning">Operating on a fallback node</p>
<p className="truncate font-mono text-[11px] tabular-nums text-warning/80">
{activeConnection.currentEndpointUrl}
</p>
</div>
)}
<div className="flex min-h-0 flex-1">
<Sidebar <Sidebar
onAddConnection={() => setConnectionDialogOpen(true)} onAddConnection={() => setConnectionDialogOpen(true)}
activeView={view.type} activeView={view.type}
@@ -156,6 +294,7 @@ function AppContent() {
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
{renderMainContent()} {renderMainContent()}
</main> </main>
</div>
<ConnectionDialog <ConnectionDialog
open={connectionDialogOpen} open={connectionDialogOpen}
onOpenChange={setConnectionDialogOpen} onOpenChange={setConnectionDialogOpen}
+8 -4
View File
@@ -35,12 +35,16 @@ export class ErrorBoundary extends Component<Props, State> {
return ( return (
<div className="flex h-full items-center justify-center p-8"> <div className="flex h-full items-center justify-center p-8">
<div className="text-center space-y-4 max-w-md"> <div className="max-w-md space-y-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto" /> <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
<h2 className="text-xl font-semibold">Something went wrong</h2> <AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-muted-foreground text-sm"> </div>
<div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">Something went wrong</h2>
<p className="font-mono text-sm text-muted-foreground">
{this.state.error?.message || 'An unexpected error occurred'} {this.state.error?.message || 'An unexpected error occurred'}
</p> </p>
</div>
<Button <Button
onClick={() => this.setState({ hasError: false, error: null })} onClick={() => this.setState({ hasError: false, error: null })}
variant="outline" variant="outline"
+71 -89
View File
@@ -1,6 +1,9 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
import { import {
Shield, Shield,
Plus, Plus,
@@ -71,9 +74,10 @@ export function BackupList({ connectionId }: BackupListProps) {
if (jobsLoading || backupsLoading) { if (jobsLoading || backupsLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <PageSkeleton filter>
<p className="text-muted-foreground">Loading backups...</p> <Skeleton className="h-56 w-full" />
</div> <Skeleton className="h-64 w-full" />
</PageSkeleton>
) )
} }
@@ -91,8 +95,8 @@ export function BackupList({ connectionId }: BackupListProps) {
{/* Header */} {/* Header */}
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-6 w-6" /> <Shield className="h-6 w-6 text-muted-foreground" />
<h2 className="text-2xl font-semibold">Backups</h2> <h2 className="text-2xl font-semibold tracking-tight">Backups</h2>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage backup jobs and existing backups Manage backup jobs and existing backups
@@ -118,9 +122,9 @@ export function BackupList({ connectionId }: BackupListProps) {
{/* Backup Jobs Section */} {/* Backup Jobs Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Backup Jobs</h3> <h3 className="text-lg font-semibold tracking-tight">Backup Jobs</h3>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}> <Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> <Plus />
Create Job Create Job
</Button> </Button>
</div> </div>
@@ -128,39 +132,39 @@ export function BackupList({ connectionId }: BackupListProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{!backupJobs || backupJobs.length === 0 ? ( {!backupJobs || backupJobs.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <EmptyState
<Shield className="h-8 w-8 mx-auto text-muted-foreground/50" /> icon={Shield}
<p>No backup jobs configured</p> title="No backup jobs configured"
<p className="text-sm mt-1">Create a backup job to get started</p> description="Create a backup job to get started"
</div> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">ID</th> <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 font-medium text-muted-foreground">Schedule</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 font-medium text-muted-foreground">Storage</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Storage</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Mode</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Mode</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Status</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{backupJobs.map((job) => ( {backupJobs.map((job) => (
<tr <tr
key={job.id} key={job.id}
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
> >
<td className="px-4 py-3 font-mono text-muted-foreground">{job.id}</td> <td className="px-4 py-3 font-mono text-muted-foreground">{job.id}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 font-mono text-xs tabular-nums">
<Clock className="h-3.5 w-3.5 text-muted-foreground" /> <Clock className="h-3.5 w-3.5 text-muted-foreground" />
{job.schedule} {job.schedule}
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 font-mono">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" /> <HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
{job.store} {job.store}
</div> </div>
@@ -172,12 +176,12 @@ export function BackupList({ connectionId }: BackupListProps) {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{job.enabled === 1 ? ( {job.enabled === 1 ? (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"> <span className="inline-flex items-center gap-1.5 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" /> <CheckCircle className="h-3 w-3" />
Enabled Enabled
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"> <span className="inline-flex items-center gap-1.5 rounded-sm border border-border bg-muted/60 px-2 py-0.5 text-xs font-medium text-muted-foreground">
<XCircle className="h-3 w-3" /> <XCircle className="h-3 w-3" />
Disabled Disabled
</span> </span>
@@ -217,29 +221,6 @@ export function BackupList({ connectionId }: BackupListProps) {
> >
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
</Button> </Button>
{confirmDeleteJob === job.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={() => {
deleteBackupJob.mutate({ id: job.id })
setConfirmDeleteJob(null)
}}
>
Confirm
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setConfirmDeleteJob(null)}
>
Cancel
</Button>
</div>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -249,7 +230,6 @@ export function BackupList({ connectionId }: BackupListProps) {
> >
<Trash className="h-3.5 w-3.5" /> <Trash className="h-3.5 w-3.5" />
</Button> </Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -264,35 +244,32 @@ export function BackupList({ connectionId }: BackupListProps) {
{/* Existing Backups Section */} {/* Existing Backups Section */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-medium">Existing Backups</h3> <h3 className="text-lg font-semibold tracking-tight">Existing Backups</h3>
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{!filteredBackups || filteredBackups.length === 0 ? ( {!filteredBackups || filteredBackups.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <EmptyState icon={HardDrive} title="No backups found" />
<HardDrive className="h-8 w-8 mx-auto text-muted-foreground/50" />
<p>No backups found</p>
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">VMID</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">VMID</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Type</th> <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 font-medium text-muted-foreground">Date</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Date</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Size</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 font-medium text-muted-foreground">Storage</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Storage</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredBackups.map((backup) => ( {filteredBackups.map((backup) => (
<tr <tr
key={backup.volid} key={backup.volid}
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
> >
<td className="px-4 py-3 font-mono text-muted-foreground"> <td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">
{backup['backup-id']} {backup['backup-id']}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -300,14 +277,14 @@ export function BackupList({ connectionId }: BackupListProps) {
{backup['backup-type']} {backup['backup-type']}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
{formatTimestamp(backup['backup-time'])} {formatTimestamp(backup['backup-time'])}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
{formatBytes(backup.size)} {formatBytes(backup.size)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 font-mono">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" /> <HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
{backup.storage} {backup.storage}
</div> </div>
@@ -323,29 +300,6 @@ export function BackupList({ connectionId }: BackupListProps) {
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</Button> </Button>
{confirmDeleteBackup === backup.volid ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={() => {
deleteBackup.mutate({ volid: backup.volid })
setConfirmDeleteBackup(null)
}}
>
Confirm
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setConfirmDeleteBackup(null)}
>
Cancel
</Button>
</div>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -355,7 +309,6 @@ export function BackupList({ connectionId }: BackupListProps) {
> >
<Trash className="h-3.5 w-3.5" /> <Trash className="h-3.5 w-3.5" />
</Button> </Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -398,6 +351,35 @@ export function BackupList({ connectionId }: BackupListProps) {
storageOptions={storageOptions} storageOptions={storageOptions}
/> />
)} )}
{/* Delete confirmations */}
<ConfirmDialog
open={confirmDeleteJob !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteJob(null)
}}
title="Delete Backup Job"
description={`Are you sure you want to delete backup job "${confirmDeleteJob}"? This action cannot be undone.`}
confirmLabel="Delete"
isLoading={deleteBackupJob.isPending}
onConfirm={() => {
if (confirmDeleteJob) deleteBackupJob.mutate({ id: confirmDeleteJob })
}}
/>
<ConfirmDialog
open={confirmDeleteBackup !== null}
onOpenChange={(open) => {
if (!open) setConfirmDeleteBackup(null)
}}
title="Delete Backup"
description={`Are you sure you want to delete this backup? This action cannot be undone.`}
confirmLabel="Delete"
isLoading={deleteBackup.isPending}
onConfirm={() => {
if (confirmDeleteBackup) deleteBackup.mutate({ volid: confirmDeleteBackup })
}}
/>
</div> </div>
) )
} }
@@ -142,7 +142,7 @@ export function CreateBackupJobDialog({
id="all" id="all"
checked={all} checked={all}
onChange={(e) => setAll(e.target.checked)} onChange={(e) => setAll(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="all" className="text-sm font-normal cursor-pointer"> <Label htmlFor="all" className="text-sm font-normal cursor-pointer">
Backup all VMs and containers Backup all VMs and containers
@@ -167,7 +167,7 @@ export function CreateBackupJobDialog({
id="enabled" id="enabled"
checked={enabled} checked={enabled}
onChange={(e) => setEnabled(e.target.checked)} onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="enabled" className="text-sm font-normal cursor-pointer"> <Label htmlFor="enabled" className="text-sm font-normal cursor-pointer">
Enabled Enabled
@@ -149,7 +149,7 @@ export function EditBackupJobDialog({
id="all" id="all"
checked={all} checked={all}
onChange={(e) => setAll(e.target.checked)} onChange={(e) => setAll(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="all" className="text-sm font-normal cursor-pointer"> <Label htmlFor="all" className="text-sm font-normal cursor-pointer">
Backup all VMs and containers Backup all VMs and containers
@@ -174,7 +174,7 @@ export function EditBackupJobDialog({
id="enabled" id="enabled"
checked={enabled} checked={enabled}
onChange={(e) => setEnabled(e.target.checked)} onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="enabled" className="text-sm font-normal cursor-pointer"> <Label htmlFor="enabled" className="text-sm font-normal cursor-pointer">
Enabled Enabled
+5 -5
View File
@@ -299,7 +299,7 @@ export function CommandPalette({
icon: Square, icon: Square,
category: 'actions', category: 'actions',
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)], keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid }), onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
}, },
{ {
id: `action-shutdown-${vm.vmid}`, id: `action-shutdown-${vm.vmid}`,
@@ -308,7 +308,7 @@ export function CommandPalette({
icon: Power, icon: Power,
category: 'actions', category: 'actions',
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)], keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid }), onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
}, },
{ {
id: `action-reboot-${vm.vmid}`, id: `action-reboot-${vm.vmid}`,
@@ -317,7 +317,7 @@ export function CommandPalette({
icon: RotateCw, icon: RotateCw,
category: 'actions', category: 'actions',
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)], keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid }), onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
}, },
) )
} }
@@ -329,7 +329,7 @@ export function CommandPalette({
icon: Play, icon: Play,
category: 'actions', category: 'actions',
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)], keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid }), onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type }),
}) })
} }
} }
@@ -538,7 +538,7 @@ export function CommandPalette({
) : ( ) : (
groupedItems.map((group, gIdx) => ( groupedItems.map((group, gIdx) => (
<div key={group.heading}> <div key={group.heading}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground select-none"> <div className="px-2 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground select-none">
{group.heading} {group.heading}
</div> </div>
{group.items.map((item, iIdx) => { {group.items.map((item, iIdx) => {
+343 -14
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,20 +11,45 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' 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 { useConnectionStore } from '@/stores/connectionStore' import { useConnectionStore } from '@/stores/connectionStore'
import { loginWithPassword, loginWithToken, addConnection } from '@/lib/tauri' import { useToast } from '@/components/ui/toast'
import type { ConnectionConfig, AuthMode } from '@/types/connection' import {
loginWithPassword,
loginWithToken,
addConnection,
updateConnection,
setActiveConnection as setActiveConnectionIPC,
connectToServer,
getCertificateInfo,
trustCertificate,
isTauri,
} from '@/lib/tauri'
import type {
ConnectionConfig,
AuthMode,
CertificateInfo,
LoginResult,
} from '@/types/connection'
interface ConnectionDialogProps { interface ConnectionDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
/** When set, the dialog edits this connection instead of creating a new one. */
editing?: ConnectionConfig
} }
export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps) { type DialogStep = 'credentials' | 'certificate' | 'connecting'
export function ConnectionDialog({ open, onOpenChange, editing }: ConnectionDialogProps) {
const addConnectionToStore = useConnectionStore((s) => s.addConnection) const addConnectionToStore = useConnectionStore((s) => s.addConnection)
const removeConnectionFromStore = useConnectionStore((s) => s.removeConnection)
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus) const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection) const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
const { addToast } = useToast()
const [step, setStep] = useState<DialogStep>('credentials')
const [authMode, setAuthMode] = useState<AuthMode>('password') const [authMode, setAuthMode] = useState<AuthMode>('password')
const [name, setName] = useState('') const [name, setName] = useState('')
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
@@ -33,18 +58,119 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
const [apiToken, setApiToken] = useState('') const [apiToken, setApiToken] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [certificate, setCertificate] = useState<CertificateInfo | null>(null)
const [pendingResult, setPendingResult] = useState<LoginResult | null>(null)
const resetForm = () => { const resetForm = () => {
setStep('credentials')
setName('') setName('')
setUrl('') setUrl('')
setUsername('') setUsername('')
setPassword('') setPassword('')
setApiToken('') setApiToken('')
setError(null) setError(null)
setCertificate(null)
setPendingResult(null)
setIsLoading(false)
}
// Prefill the form when the dialog opens in edit mode. The URL is immutable
// (changing servers is a remove + re-add), so it is populated read-only.
useEffect(() => {
if (open && editing) {
setStep('credentials')
setName(editing.name)
setUrl(editing.primary.url)
setUsername(editing.username ?? '')
setAuthMode(editing.authMode)
setPassword('')
setApiToken('')
setError(null)
setCertificate(null)
setPendingResult(null)
setIsLoading(false)
}
}, [open, editing])
const loadCertificate = async (cleanUrl: string) => {
setIsLoading(true)
setError(null)
try {
const info = await getCertificateInfo(cleanUrl)
setCertificate(info)
} catch (err) {
setCertificate(null)
setError(err instanceof Error ? err.message : 'Failed to fetch certificate')
} finally {
setIsLoading(false)
}
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
}
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!editing) return
setIsLoading(true)
setError(null)
try {
const cleanUrl = editing.primary.url.replace(/\/$/, '')
// Re-validate credentials only when a new password/token was entered;
// this also refreshes the stored keyring credentials for the connection.
if (authMode === 'password') {
if (!username) {
throw new Error('Username is required')
}
if (password) {
await loginWithPassword(cleanUrl, username, password)
}
} else if (apiToken) {
await loginWithToken(cleanUrl, apiToken)
}
const config: ConnectionConfig = {
id: editing.id,
name: name || editing.name,
primary: {
url: cleanUrl,
token: authMode === 'token' ? apiToken || editing.primary.token : undefined,
},
fallbacks: editing.fallbacks,
certFingerprint: editing.certFingerprint,
trusted: editing.trusted,
acceptUntrusted: editing.acceptUntrusted,
status: editing.status,
isCluster: editing.isCluster,
authMode,
username: authMode === 'password' ? username : undefined,
nodes: editing.nodes,
clusterId: editing.clusterId,
}
await updateConnection(config)
useConnectionStore.getState().updateConnection(config.id, config)
addToast(`Connection "${config.name}" updated`, 'success')
resetForm()
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update connection')
} finally {
setIsLoading(false)
}
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (editing) {
await handleEditSubmit(e)
return
}
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -68,6 +194,8 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
result = await loginWithToken(cleanUrl, apiToken) result = await loginWithToken(cleanUrl, apiToken)
} }
if (!isTauri()) {
// Browser mock mode: keep the single-step flow
const connectionId = result.connectionId || crypto.randomUUID() const connectionId = result.connectionId || crypto.randomUUID()
const config: ConnectionConfig = { const config: ConnectionConfig = {
@@ -92,6 +220,13 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
resetForm() resetForm()
onOpenChange(false) onOpenChange(false)
return
}
// Tauri mode: review the server certificate before trusting it
setPendingResult(result)
setStep('certificate')
await loadCertificate(cleanUrl)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to connect') setError(err instanceof Error ? err.message : 'Failed to connect')
} finally { } finally {
@@ -99,13 +234,93 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
} }
} }
const handleTrustAndConnect = async () => {
if (!certificate || !pendingResult) return
const connectionId = pendingResult.connectionId || crypto.randomUUID()
const cleanUrl = url.replace(/\/$/, '')
const connectionName = name || (authMode === 'password' ? username : 'API Token Connection')
setStep('connecting')
setError(null)
try {
const config: ConnectionConfig = {
id: connectionId,
name: connectionName,
primary: {
url: cleanUrl,
token: authMode === 'token' ? apiToken : undefined,
},
fallbacks: [],
certFingerprint: certificate.fingerprint,
trusted: true,
acceptUntrusted: false,
status: 'connecting',
isCluster: false,
authMode,
username: authMode === 'password' ? username : undefined,
}
// The backend requires the connection to exist before pinning its
// certificate, so register it first, then trust, then connect.
await addConnection(config)
await trustCertificate(connectionId, certificate.fingerprint)
await setActiveConnectionIPC(connectionId)
addConnectionToStore(config)
setActiveConnection(connectionId)
setConnectionStatus(connectionId, 'connecting')
setAuthStatus('authenticated')
const result = await connectToServer(connectionId)
if (result.status === 'failed') {
setConnectionStatus(connectionId, 'failed')
addToast('Could not reach any cluster node', 'error')
resetForm()
onOpenChange(false)
return
}
if (result.mergedInto && result.mergedInto !== connectionId) {
// The backend folded this connection into an existing same-cluster
// connection; mirror the merge in the store.
removeConnectionFromStore(connectionId)
setActiveConnection(result.mergedInto)
setConnectionStatus(result.mergedInto, result.status)
setAuthStatus('authenticated')
addToast('Connected to existing cluster — added as a failover endpoint.', 'success')
resetForm()
onOpenChange(false)
return
}
setConnectionStatus(connectionId, 'connected')
addToast(`Connected to ${connectionName}`, 'success')
resetForm()
onOpenChange(false)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect'
setConnectionStatus(connectionId, 'failed')
addToast(message, 'error')
resetForm()
onOpenChange(false)
}
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent> <DialogContent>
{step === 'credentials' && (
<>
<DialogHeader> <DialogHeader>
<DialogTitle>Connect to Proxmox</DialogTitle> <DialogTitle>{editing ? 'Edit Connection' : 'Connect to Proxmox'}</DialogTitle>
<DialogDescription> <DialogDescription>
Sign in with your Proxmox credentials or API token {editing
? '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'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -124,9 +339,13 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
required required
readOnly={!!editing}
disabled={!!editing}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The URL of your Proxmox server (must use HTTPS) {editing
? 'Server URL cannot be changed while editing'
: 'The URL of your Proxmox server (must use HTTPS)'}
</p> </p>
</div> </div>
@@ -149,10 +368,10 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="Your password" placeholder={editing ? 'Leave blank to keep current password' : 'Your password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required={!editing}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -163,10 +382,10 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
<Input <Input
id="token" id="token"
type="password" type="password"
placeholder="user@realm!tokenid=secret" placeholder={editing ? 'Leave blank to keep current token' : 'user@realm!tokenid=secret'}
value={apiToken} value={apiToken}
onChange={(e) => setApiToken(e.target.value)} onChange={(e) => setApiToken(e.target.value)}
required required={!editing}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Format: user@realm!tokenid=secret Format: user@realm!tokenid=secret
@@ -191,15 +410,125 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
)} )}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isLoading}> <Button type="submit" disabled={isLoading}>
{isLoading ? 'Connecting...' : 'Connect'} {isLoading
? (editing ? 'Saving...' : 'Connecting...')
: (editing ? 'Save Changes' : 'Connect')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Tabs> </Tabs>
</>
)}
{step === 'certificate' && (
<>
<DialogHeader>
<DialogTitle>Verify Server Certificate</DialogTitle>
<DialogDescription>
Review the certificate presented by {url}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{isLoading && (
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Fetching certificate...</span>
</div>
)}
{!isLoading && certificate && (
<>
<div className="flex items-start gap-2 rounded-md bg-destructive/10 border border-destructive/20 p-3">
<ShieldAlert className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive">
This is a self-signed or untrusted certificate. Verify the fingerprint
against your Proxmox server&apos;s SSL certificate before trusting.
</p>
</div>
<div className="space-y-3 rounded-md border border-border/70 bg-muted/20 p-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-4 text-sm">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Subject</p>
<p className="mt-1 break-all font-mono text-xs">{certificate.subject || '-'}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Issuer</p>
<p className="mt-1 break-all font-mono text-xs">{certificate.issuer || '-'}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Valid From</p>
<p className="mt-1 font-mono text-xs tabular-nums">{certificate.validFrom || '-'}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Valid To</p>
<p className="mt-1 font-mono text-xs tabular-nums">{certificate.validTo || '-'}</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Self-signed</p>
<p className="mt-1 font-medium">
{certificate.selfSigned ? 'Yes' : 'No'}
</p>
</div>
</div>
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Fingerprint (SHA-256)</p>
<code className="block rounded-md border border-border/70 bg-muted/50 px-3 py-2 font-mono text-xs break-all">
{certificate.fingerprint}
</code>
</div>
</div>
</>
)}
{!isLoading && !certificate && error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
{certificate ? (
<Button type="button" onClick={handleTrustAndConnect} disabled={isLoading}>
Trust and Connect
</Button>
) : (
<Button
type="button"
onClick={() => loadCertificate(url.replace(/\/$/, ''))}
disabled={isLoading}
>
{isLoading ? 'Retrying...' : 'Retry'}
</Button>
)}
</DialogFooter>
</div>
</>
)}
{step === 'connecting' && (
<>
<DialogHeader>
<DialogTitle>Connecting</DialogTitle>
<DialogDescription>
Establishing a connection to {url}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm">Trusting certificate and connecting...</span>
</div>
</>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
+39 -23
View File
@@ -1,6 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Activity, Clock } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton'
import { Activity, Clock, Loader2 } from 'lucide-react'
import type { ProxmoxTask } from '@/types/proxmox' import type { ProxmoxTask } from '@/types/proxmox'
interface ActivityFeedProps { interface ActivityFeedProps {
@@ -8,17 +9,21 @@ interface ActivityFeedProps {
isLoading: boolean isLoading: boolean
} }
function isRunningTask(status?: string): boolean {
return !status || status === 'Running'
}
function getStatusBadgeClass(status?: string): string { function getStatusBadgeClass(status?: string): string {
switch (status) { switch (status) {
case 'OK': case 'OK':
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' return 'border-success/25 bg-success/10 text-success'
case 'unknown': case 'unknown':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' return 'border-warning/25 bg-warning/10 text-warning'
default: default:
if (!status || status === 'Running') { if (!status || status === 'Running') {
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' return 'border-info/25 bg-info/10 text-info'
} }
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' return 'border-destructive/25 bg-destructive/10 text-destructive'
} }
} }
@@ -65,13 +70,19 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Activity className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<CardTitle>Recent Activity</CardTitle> <Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground text-center py-4">Loading tasks...</p> <div className="space-y-2">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -82,38 +93,43 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Activity className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<CardTitle>Recent Activity</CardTitle> <Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recentTasks.length > 0 ? ( {recentTasks.length > 0 ? (
<ScrollArea className="h-[300px]"> <ScrollArea className="h-[300px]">
<div className="space-y-2"> <div className="space-y-1.5">
{recentTasks.map((task) => ( {recentTasks.map((task) => (
<div <div
key={task.upid} key={task.upid}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors" className="flex items-center justify-between rounded-md border border-border/70 bg-background/30 px-3 py-2.5 transition-colors duration-150 hover:border-border hover:bg-accent/40"
> >
<div className="flex items-center gap-3 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium truncate"> <p className="truncate text-[13px] font-medium">{getTaskTypeLabel(task.type)}</p>
{getTaskTypeLabel(task.type)}
</p>
<span <span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${getStatusBadgeClass(task.status)}`} className={`inline-flex shrink-0 items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[11px] font-medium ${getStatusBadgeClass(task.status)}`}
> >
{isRunningTask(task.status) && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{getStatusLabel(task.status)} {getStatusLabel(task.status)}
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground truncate"> <p className="truncate text-xs text-muted-foreground">
{task.node} &middot; {task.user} <span className="font-mono tabular-nums">{task.node}</span>
<span> · </span>
{task.user}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0 ml-3"> <div className="ml-3 flex shrink-0 items-center gap-3 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span>{formatDuration(task.starttime, task.endtime)}</span> <span>{formatDuration(task.starttime, task.endtime)}</span>
@@ -125,7 +141,7 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
</div> </div>
</ScrollArea> </ScrollArea>
) : ( ) : (
<p className="text-sm text-muted-foreground text-center py-4">No recent activity</p> <p className="py-4 text-center text-sm text-muted-foreground">No recent activity</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
+52 -37
View File
@@ -4,6 +4,7 @@ import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
import { AreaChart, Area, ResponsiveContainer } from 'recharts' import { AreaChart, Area, ResponsiveContainer } from 'recharts'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox' import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format' import { formatBytes, formatUptime } from '@/lib/format'
import { cn } from '@/lib/utils'
interface NodeHealthGridProps { interface NodeHealthGridProps {
nodes: ProxmoxNode[] | undefined nodes: ProxmoxNode[] | undefined
@@ -11,7 +12,13 @@ interface NodeHealthGridProps {
} }
function getStatusColor(status: 'online' | 'offline'): string { function getStatusColor(status: 'online' | 'offline'): string {
return status === 'online' ? 'bg-green-500' : 'bg-red-500' return status === 'online'
? 'bg-success shadow-[0_0_6px] shadow-success/60'
: 'bg-destructive'
}
function getStatusTextColor(status: 'online' | 'offline'): string {
return status === 'online' ? 'text-success' : 'text-destructive'
} }
function getStatusLabel(status: 'online' | 'offline'): string { function getStatusLabel(status: 'online' | 'offline'): string {
@@ -40,20 +47,24 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.status)}`} /> <span className={cn('h-2 w-2 rounded-full', getStatusColor(node.status))} />
<CardTitle className="text-sm font-medium">{node.node}</CardTitle> <CardTitle className="font-mono text-sm font-semibold tracking-tight">
{node.node}
</CardTitle>
</div> </div>
<span className="text-xs text-muted-foreground">{getStatusLabel(node.status)}</span> <span className={cn('text-xs font-medium', getStatusTextColor(node.status))}>
{getStatusLabel(node.status)}
</span>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Sparklines */} {/* Sparklines */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="mb-1 flex items-center gap-1.5">
<Cpu className="h-3 w-3 text-muted-foreground" /> <Cpu className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">CPU</span> <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">CPU</span>
</div> </div>
<div className="h-8"> <div className="h-8">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -61,9 +72,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
stroke="hsl(var(--color-primary))" stroke="var(--color-primary)"
fill="hsl(var(--color-primary))" fill="var(--color-primary)"
fillOpacity={0.2} fillOpacity={0.18}
strokeWidth={1.5} strokeWidth={1.5}
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
@@ -73,9 +84,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-center gap-1 mb-1"> <div className="mb-1 flex items-center gap-1.5">
<MemoryStick className="h-3 w-3 text-muted-foreground" /> <MemoryStick className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">RAM</span> <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">RAM</span>
</div> </div>
<div className="h-8"> <div className="h-8">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -83,9 +94,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
<Area <Area
type="monotone" type="monotone"
dataKey="value" dataKey="value"
stroke="hsl(var(--color-primary))" stroke="var(--color-primary)"
fill="hsl(var(--color-primary))" fill="var(--color-primary)"
fillOpacity={0.2} fillOpacity={0.18}
strokeWidth={1.5} strokeWidth={1.5}
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
@@ -97,33 +108,33 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
</div> </div>
{/* Stats row */} {/* Stats row */}
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground"> <div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3" /> <Cpu className="h-3 w-3 shrink-0" />
<span>{(cpuPercent * 100).toFixed(0)}%</span> <span>{(cpuPercent * 100).toFixed(0)}%</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<MemoryStick className="h-3 w-3" /> <MemoryStick className="h-3 w-3 shrink-0" />
<span>{(memPercent * 100).toFixed(0)}%</span> <span>{(memPercent * 100).toFixed(0)}%</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3" /> <HardDrive className="h-3 w-3 shrink-0" />
<span>{(diskPercent * 100).toFixed(0)}%</span> <span>{(diskPercent * 100).toFixed(0)}%</span>
</div> </div>
</div> </div>
{/* Bottom info */} {/* Bottom info */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1 border-t"> <div className="flex items-center justify-between border-t pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3 shrink-0" />
<span>{formatUptime(node.uptime)}</span> <span>{formatUptime(node.uptime)}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Box className="h-3 w-3" /> <Box className="h-3 w-3 shrink-0" />
<span>{vmCount} VMs</span> <span>{vmCount} VMs</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3" /> <HardDrive className="h-3 w-3 shrink-0" />
<span>{formatBytes(node.disk)}</span> <span>{formatBytes(node.disk)}</span>
</div> </div>
</div> </div>
@@ -146,13 +157,15 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Server className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<CardTitle>Node Health</CardTitle> <Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground text-center py-4">No nodes available</p> <p className="py-4 text-center text-sm text-muted-foreground">No nodes available</p>
</CardContent> </CardContent>
</Card> </Card>
) )
@@ -161,9 +174,11 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<Server className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<CardTitle>Node Health</CardTitle> <Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+20 -15
View File
@@ -1,6 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield } from 'lucide-react' import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield, Zap } from 'lucide-react'
interface QuickActionsProps { interface QuickActionsProps {
onRefresh: () => void onRefresh: () => void
@@ -11,59 +11,64 @@ interface QuickActionsProps {
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) { export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle> <div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Quick Actions</CardTitle>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing}
> >
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="text-xs">Refresh</span> <span className="text-xs font-medium">Refresh</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('dashboard')} onClick={() => onNavigate?.('dashboard')}
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
<span className="text-xs">Nodes</span> <span className="text-xs font-medium">Nodes</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('vms')} onClick={() => onNavigate?.('vms')}
> >
<Box className="h-4 w-4" /> <Box className="h-4 w-4" />
<span className="text-xs">VMs</span> <span className="text-xs font-medium">VMs</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('storage')} onClick={() => onNavigate?.('storage')}
> >
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
<span className="text-xs">Storage</span> <span className="text-xs font-medium">Storage</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('tasks')} onClick={() => onNavigate?.('tasks')}
> >
<ListTodo className="h-4 w-4" /> <ListTodo className="h-4 w-4" />
<span className="text-xs">Tasks</span> <span className="text-xs font-medium">Tasks</span>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="flex flex-col items-center gap-1.5 h-auto py-3" className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
onClick={() => onNavigate?.('backups')} onClick={() => onNavigate?.('backups')}
> >
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
<span className="text-xs">Backups</span> <span className="text-xs font-medium">Backups</span>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
+30 -11
View File
@@ -20,10 +20,23 @@ function formatCores(cores: number): string {
return `${cores.toFixed(1)} cores` return `${cores.toFixed(1)} cores`
} }
function getGaugeColor(percent: number): string { function getGaugeStyle(percent: number): { className: string; glow: string } {
if (percent >= 0.9) return 'stroke-destructive' if (percent >= 0.9) {
if (percent >= 0.7) return 'stroke-yellow-500' return {
return 'stroke-primary' className: 'stroke-destructive',
glow: 'drop-shadow(0 0 3px color-mix(in oklab, var(--color-destructive) 50%, transparent))',
}
}
if (percent >= 0.7) {
return {
className: 'stroke-warning',
glow: 'drop-shadow(0 0 3px color-mix(in oklab, var(--color-warning) 50%, transparent))',
}
}
return {
className: 'stroke-primary',
glow: 'drop-shadow(0 0 3px color-mix(in oklab, var(--color-primary) 45%, transparent))',
}
} }
function getTrackColor(): string { function getTrackColor(): string {
@@ -49,16 +62,19 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
: formatBytes : formatBytes
const formatter = formatValue ?? defaultFormat const formatter = formatValue ?? defaultFormat
const gaugeStyle = getGaugeStyle(clampedPercent)
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">{label}</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-3"> <CardContent className="flex flex-col items-center gap-3">
<div className="relative"> <div className="relative">
<svg width="100" height="100" viewBox="0 0 100 100"> <svg width="108" height="108" viewBox="0 0 100 100">
{/* Background track */} {/* Background track */}
<circle <circle
cx="50" cx="50"
@@ -78,19 +94,22 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
className={getGaugeColor(clampedPercent)} className={gaugeStyle.className}
style={{ style={{
transform: 'rotate(-90deg)', transform: 'rotate(-90deg)',
transformOrigin: '50% 50%', transformOrigin: '50% 50%',
transition: 'stroke-dashoffset 0.6s ease-in-out', transition: 'stroke-dashoffset 0.6s ease-in-out',
filter: gaugeStyle.glow,
}} }}
/> />
</svg> </svg>
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-lg font-bold">{displayPercent}%</span> <span className="font-mono text-xl font-semibold leading-none tracking-tight tabular-nums">
{displayPercent}%
</span>
</div> </div>
</div> </div>
<div className="text-center text-xs text-muted-foreground"> <div className="text-center font-mono text-xs tabular-nums text-muted-foreground">
{formatter(used)} / {formatter(total)} {formatter(used)} / {formatter(total)}
</div> </div>
</CardContent> </CardContent>
+55 -36
View File
@@ -9,6 +9,7 @@ import { ActivityFeed } from '@/components/dashboard/ActivityFeed'
import { QuickActions } from '@/components/dashboard/QuickActions' import { QuickActions } from '@/components/dashboard/QuickActions'
import { formatBytes } from '@/lib/format' import { formatBytes } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { PageSkeleton } from '@/components/ui/skeleton'
interface DashboardProps { interface DashboardProps {
connectionId: string connectionId: string
@@ -36,27 +37,32 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) }) queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connectionId) })
}, [queryClient, connectionId]) }, [queryClient, connectionId])
const handleRetry = useCallback(() => {
queryClient.refetchQueries({ queryKey: queryKeys.nodes(connectionId) })
queryClient.refetchQueries({ queryKey: queryKeys.vms(connectionId) })
queryClient.refetchQueries({ queryKey: queryKeys.tasks(connectionId) })
}, [queryClient, connectionId])
if (nodesLoading || vmsLoading) { if (nodesLoading || vmsLoading) {
return ( return <PageSkeleton />
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
)
} }
const hasError = nodesError || vmsError || tasksError const hasError = nodesError || vmsError || tasksError
if (hasError) { if (hasError) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center p-6">
<div className="text-center space-y-4"> <div className="flex flex-col items-center text-center">
<AlertCircle className="h-12 w-12 text-destructive mx-auto" /> <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
<div> <AlertCircle className="h-5 w-5 text-destructive" />
<h3 className="text-lg font-semibold">Failed to Load Dashboard</h3> </div>
<p className="text-sm text-muted-foreground mt-1"> <h3 className="text-lg font-semibold tracking-tight">Unable to load dashboard</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'} {nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
</p> </p>
</div> <p className="mt-1 max-w-sm text-sm text-muted-foreground">
<Button variant="outline" onClick={() => window.location.reload()}> Check the connection to your Proxmox server and try again.
</p>
<Button variant="outline" className="mt-4" onClick={handleRetry}>
Retry Retry
</Button> </Button>
</div> </div>
@@ -68,65 +74,78 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
<div className="h-full overflow-auto p-6"> <div className="h-full overflow-auto p-6">
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold">Dashboard</h2> <h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
<p className="text-muted-foreground">Cluster overview and resource usage</p> Dashboard
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Cluster overview and resource usage
</p>
</div> </div>
{/* Summary Stats Row */} {/* Summary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">Nodes</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" /> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{nodes?.filter((n) => n.status === 'online').length ?? 0} / {nodes?.length ?? 0} {nodes?.filter((n) => n.status === 'online').length ?? 0}
<span className="text-muted-foreground"> / {nodes?.length ?? 0}</span>
</div> </div>
<p className="text-xs text-muted-foreground">Online</p> <p className="mt-1.5 text-xs text-muted-foreground">Online</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" /> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'} {totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{usedCPU.toFixed(1)} / {totalCPU} cores {usedCPU.toFixed(1)} / {totalCPU} cores
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">Memory</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
<MemoryStick className="h-4 w-4 text-muted-foreground" /> <div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'} {totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedMem)} / {formatBytes(totalMem)} {formatBytes(usedMem)} / {formatBytes(totalMem)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium">Storage</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" /> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'} {totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedDisk)} / {formatBytes(totalDisk)} {formatBytes(usedDisk)} / {formatBytes(totalDisk)}
</p> </p>
</CardContent> </CardContent>
+92 -23
View File
@@ -1,15 +1,19 @@
import { useConnectionStore } from '@/stores/connectionStore' import { useConnectionStore } from '@/stores/connectionStore'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings } 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' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings' type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'nodes' | 'node-detail' | 'containers' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings'
type NavigationTarget =
| { type: ViewType }
| { type: 'node-detail'; nodeName: string }
interface SidebarProps { interface SidebarProps {
onAddConnection: () => void onAddConnection: () => void
activeView?: ViewType activeView?: ViewType
onNavigate?: (view: { type: ViewType }) => void onNavigate?: (view: NavigationTarget) => void
} }
export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProps) { export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProps) {
@@ -20,39 +24,58 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'connected': case 'connected':
return 'bg-green-500' return 'bg-success shadow-[0_0_6px] shadow-success/60'
case 'connecting': case 'connecting':
case 'failover': case 'failover':
return 'bg-yellow-500' return 'bg-warning'
case 'failed': case 'failed':
return 'bg-red-500' return 'bg-destructive'
default: default:
return 'bg-gray-500' return 'bg-muted-foreground/60'
} }
} }
return ( return (
<div className="w-64 border-r bg-card flex flex-col"> <div className="flex w-64 flex-col border-r border-border bg-card">
<div className="p-4 border-b"> <div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
<h1 className="text-lg font-semibold">ProxmoxDesktop</h1> <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/15 text-primary">
<Hexagon className="h-4 w-4" />
</div>
<div className="min-w-0 leading-tight">
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">
ProxmoxDesktop
</h1>
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Ops Console
</p>
</div>
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1"> <div className="space-y-1 p-2">
{connections.map((connection) => ( {connections.map((connection) => (
<div key={connection.id}> <div key={connection.id}>
<button <button
onClick={() => setActiveConnection(connection.id)} onClick={() => setActiveConnection(connection.id)}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors', 'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150',
activeConnectionId === connection.id activeConnectionId === connection.id
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)} )}
> >
<div className={cn('w-2 h-2 rounded-full', getStatusColor(connection.status))} /> <span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusColor(connection.status))} />
<span className="font-medium truncate">{connection.name}</span> <span className="truncate font-medium">{connection.name}</span>
</button> </button>
{activeConnectionId === connection.id && ( {activeConnectionId === connection.id && (
<>
{connection.status === 'failover' && connection.currentEndpointUrl && (
<p className="ml-7 mt-1 truncate font-mono text-[10px] tabular-nums text-warning">
Failover: {connection.currentEndpointUrl}
</p>
)}
{(connection.status === 'failed' || connection.status === 'disconnected') && (
<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 <SidebarItem
icon={LayoutDashboard} icon={LayoutDashboard}
@@ -60,24 +83,67 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
active={activeView === 'dashboard'} active={activeView === 'dashboard'}
onClick={() => onNavigate?.({ type: 'dashboard' })} onClick={() => onNavigate?.({ type: 'dashboard' })}
/> />
<SidebarItem icon={Server} label="Nodes" disabled /> <SidebarItem
icon={Server}
label="Nodes"
active={activeView === 'nodes'}
onClick={() => onNavigate?.({ type: 'nodes' })}
/>
<SidebarItem <SidebarItem
icon={Box} icon={Box}
label="VMs" label="VMs"
active={activeView === 'vms' || activeView === 'vm-detail'} active={activeView === 'vms' || activeView === 'vm-detail'}
onClick={() => onNavigate?.({ type: 'vms' })} onClick={() => onNavigate?.({ type: 'vms' })}
/> />
<SidebarItem icon={Box} label="Containers" disabled /> <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={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={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} /> <SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
{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">
Cluster nodes
</p>
{connection.nodes.map((node) => (
<button
key={node.url}
onClick={() => onNavigate?.({ type: 'node-detail', nodeName: node.name })}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground"
>
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
node.status === 'online' ? 'bg-success' : 'bg-muted-foreground/40',
)}
/>
<span className="min-w-0 flex-1 truncate font-mono">{node.name}</span>
{node.isPrimary && (
<span className="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px text-[10px] uppercase text-primary">
primary
</span>
)}
{node.status === 'offline' && (
<span className="shrink-0 text-[10px] text-muted-foreground">
offline
</span>
)}
</button>
))}
</>
)}
</div> </div>
</>
)} )}
</div> </div>
))} ))}
</div> </div>
</ScrollArea> </ScrollArea>
<div className="p-2 border-t space-y-1"> <div className="space-y-1 border-t border-border p-2">
<SidebarItem <SidebarItem
icon={Settings} icon={Settings}
label="Settings" label="Settings"
@@ -115,15 +181,18 @@ function SidebarItem({
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors', 'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150',
disabled disabled
? 'opacity-50 cursor-not-allowed pointer-events-none' ? 'pointer-events-none cursor-not-allowed opacity-50'
: active : active
? 'bg-accent text-accent-foreground' ? 'bg-primary/10 text-primary hover:bg-primary/15'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)} )}
> >
<Icon className="h-4 w-4" /> {active && (
<span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
)}
<Icon className="h-4 w-4 shrink-0" />
<span>{label}</span> <span>{label}</span>
</button> </button>
) )
+36 -33
View File
@@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { StatusBadge } from '@/components/ui/status-badge' import { StatusBadge } from '@/components/ui/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react' import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox' import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxNode } from '@/types/proxmox' import type { ProxmoxNode } from '@/types/proxmox'
@@ -18,9 +19,9 @@ function ResourceBar({ label, used, total, icon: Icon }: {
}) { }) {
const percent = total > 0 ? (used / total) * 100 : 0 const percent = total > 0 ? (used / total) * 100 : 0
const color = const color =
percent > 90 ? 'bg-red-500' : percent > 90 ? 'bg-destructive' :
percent > 70 ? 'bg-yellow-500' : percent > 70 ? 'bg-warning' :
'bg-green-500' 'bg-success'
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -29,17 +30,17 @@ function ResourceBar({ label, used, total, icon: Icon }: {
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-4 w-4 text-muted-foreground" />
{label} {label}
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="font-mono text-sm tabular-nums text-muted-foreground">
{percent.toFixed(1)}% {percent.toFixed(1)}%
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-secondary overflow-hidden"> <div className="h-2 rounded-full bg-secondary overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${color}`} className={`h-full rounded-full transition-all duration-500 ${color}`}
style={{ width: `${Math.min(percent, 100)}%` }} style={{ width: `${Math.min(percent, 100)}%` }}
/> />
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(used)} / {formatBytes(total)} {formatBytes(used)} / {formatBytes(total)}
</div> </div>
</div> </div>
@@ -60,10 +61,10 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Server className="h-6 w-6 text-muted-foreground" /> <Server className="h-6 w-6 text-muted-foreground" />
<h2 className="text-2xl font-semibold">{node.node}</h2> <h2 className="font-mono text-2xl font-semibold tracking-tight">{node.node}</h2>
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
<p className="text-muted-foreground"> <p className="font-mono text-sm tabular-nums text-muted-foreground">
Uptime: {formatUptime(node.uptime)} Uptime: {formatUptime(node.uptime)}
</p> </p>
</div> </div>
@@ -79,7 +80,7 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
total={node.maxcpu} total={node.maxcpu}
icon={Cpu} icon={Cpu}
/> />
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-2 font-mono text-sm tabular-nums text-muted-foreground">
{node.cpu.toFixed(1)} / {node.maxcpu} cores {node.cpu.toFixed(1)} / {node.maxcpu} cores
</div> </div>
</CardContent> </CardContent>
@@ -113,15 +114,17 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Virtual Machines & Containers</CardTitle> <CardTitle>Virtual Machines & Containers</CardTitle>
<span className="text-sm text-muted-foreground"> <span className="font-mono text-sm tabular-nums text-muted-foreground">
{runningVMs.length} running / {nodeVMs.length} total {runningVMs.length} running / {nodeVMs.length} total
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{vmsLoading ? ( {vmsLoading ? (
<div className="text-center py-8 text-muted-foreground"> <div className="space-y-2">
Loading... {Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div> </div>
) : nodeVMs.length === 0 ? ( ) : nodeVMs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
@@ -132,22 +135,22 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
{nodeVMs.map((vm) => ( {nodeVMs.map((vm) => (
<div <div
key={`${vm.type}-${vm.vmid}`} key={`${vm.type}-${vm.vmid}`}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer" className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2.5 transition-colors duration-150 hover:bg-accent/50 cursor-pointer"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm text-muted-foreground"> <span className="font-mono text-sm tabular-nums text-muted-foreground">
{vm.vmid} {vm.vmid}
</span> </span>
<span className="font-medium">{vm.name}</span> <span className="font-medium">{vm.name}</span>
</div> </div>
<span className="text-xs text-muted-foreground uppercase"> <span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{vm.type} {vm.type}
</span> </span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<StatusBadge status={vm.status} /> <StatusBadge status={vm.status} />
<div className="text-sm text-muted-foreground"> <div className="font-mono text-sm tabular-nums text-muted-foreground">
{vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'} {vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'}
</div> </div>
</div> </div>
@@ -164,38 +167,38 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
<CardTitle>System Information</CardTitle> <CardTitle>System Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-x-4 gap-y-5 text-sm">
<div> <div>
<span className="text-muted-foreground">Node Name</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node Name</span>
<p className="font-medium">{node.node}</p> <p className="mt-1 font-mono font-medium">{node.node}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Status</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</span>
<p className="font-medium capitalize">{node.status}</p> <p className="mt-1 font-medium capitalize">{node.status}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Uptime</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</span>
<p className="font-medium">{formatUptime(node.uptime)}</p> <p className="mt-1 font-mono font-medium tabular-nums">{formatUptime(node.uptime)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Support Level</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Support Level</span>
<p className="font-medium">{node.level || 'None'}</p> <p className="mt-1 font-medium">{node.level || 'None'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Total CPUs</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total CPUs</span>
<p className="font-medium">{node.maxcpu}</p> <p className="mt-1 font-mono font-medium tabular-nums">{node.maxcpu}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Total Memory</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Memory</span>
<p className="font-medium">{formatBytes(node.maxmem)}</p> <p className="mt-1 font-mono font-medium tabular-nums">{formatBytes(node.maxmem)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Total Disk</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Disk</span>
<p className="font-medium">{formatBytes(node.maxdisk)}</p> <p className="mt-1 font-mono font-medium tabular-nums">{formatBytes(node.maxdisk)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">ID</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">ID</span>
<p className="font-medium font-mono text-xs">{node.id}</p> <p className="mt-1 font-mono text-xs tabular-nums">{node.id}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from 'react'
import { StatusBadge } from '@/components/ui/status-badge'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { useNodes } from '@/hooks/useProxmox'
import { NodeDetail } from '@/components/nodes/NodeDetail'
import { formatBytes, formatUptime } from '@/lib/format'
import { cn } from '@/lib/utils'
interface NodesPageProps {
connectionId: string
initialNodeName?: string
onNavigate?: (view: string) => void
}
function nodePercent(used: number, total: number): number {
return total > 0 ? Math.round((used / total) * 100) : 0
}
function MiniStat({
label,
icon: Icon,
value,
}: {
label: string
icon: React.ComponentType<{ className?: string }>
value: string
}) {
return (
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1 text-muted-foreground">
<Icon className="h-3 w-3" />
{label}
</span>
<span className="font-mono font-medium tabular-nums">{value}</span>
</div>
)
}
export function NodesPage({ connectionId, initialNodeName }: NodesPageProps) {
const { data: nodes, isLoading, error } = useNodes(connectionId)
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null)
// Auto-select the node requested via the sidebar (node-detail view) when it
// is present; otherwise default-select the first node. Keep the selection
// valid as the node list refreshes by falling back to the first node when
// the selection disappears.
useEffect(() => {
if (nodes && nodes.length > 0) {
setSelectedNodeName((current) => {
if (initialNodeName && nodes.some((n) => n.node === initialNodeName)) {
return initialNodeName
}
return nodes.some((n) => n.node === current) ? current : nodes[0].node
})
}
}, [nodes, initialNodeName])
const selectedNode = useMemo(() => {
if (!nodes || nodes.length === 0) return undefined
return nodes.find((n) => n.node === selectedNodeName) ?? nodes[0]
}, [nodes, selectedNodeName])
if (isLoading) {
return (
<div className="flex h-full overflow-hidden">
<div className="w-72 shrink-0 border-r p-3 space-y-3">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-32" />
</div>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-28 w-full" />
))}
</div>
<div className="flex-1 p-6 space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Skeleton className="h-36" />
<Skeleton className="h-36" />
<Skeleton className="h-36" />
</div>
<Skeleton className="h-56 w-full" />
</div>
</div>
)
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-destructive">Failed to load nodes</p>
</div>
)
}
if (!nodes || nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState icon={Server} title="No nodes found" />
</div>
)
}
return (
<div className="flex h-full overflow-hidden">
{/* Node list */}
<div className="w-72 shrink-0 border-r overflow-y-auto p-3 space-y-3">
<div>
<h2 className="text-xl font-semibold tracking-tight">Nodes</h2>
<p className="text-sm text-muted-foreground">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} in the cluster
</p>
</div>
<div className="space-y-2">
{nodes.map((node) => (
<button
key={node.node}
onClick={() => setSelectedNodeName(node.node)}
className={cn(
'w-full text-left rounded-md border bg-card p-3 transition-colors duration-150',
selectedNode?.node === node.node
? 'border-primary/40 bg-primary/10'
: 'border-border/70 hover:bg-accent/50',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Server className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate font-mono font-medium">{node.node}</span>
</div>
<StatusBadge status={node.status} />
</div>
<p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
Uptime: {formatUptime(node.uptime)}
</p>
<div className="mt-2 space-y-1">
<MiniStat
label="CPU"
icon={Cpu}
value={`${nodePercent(node.cpu * node.maxcpu, node.maxcpu)}%`}
/>
<MiniStat label="Memory" icon={MemoryStick} value={formatBytes(node.mem)} />
<MiniStat label="Disk" icon={HardDrive} value={formatBytes(node.disk)} />
</div>
</button>
))}
</div>
</div>
{/* Selected node detail */}
<div className="flex-1 overflow-hidden">
{selectedNode && (
<NodeDetail node={selectedNode} connectionId={connectionId} />
)}
</div>
</div>
)
}
+36 -34
View File
@@ -2,45 +2,49 @@ import { useState } from 'react'
import { useConnectionStore } from '@/stores/connectionStore' import { useConnectionStore } from '@/stores/connectionStore'
import { useToast } from '@/components/ui/toast' import { useToast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { ConnectionDialog } from '@/components/connections/ConnectionDialog' import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
import { Plus, Pencil, Trash2 } from 'lucide-react' import { Plus, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ConnectionConfig } from '@/types/connection'
export function ConnectionManager() { export function ConnectionManager() {
const connections = useConnectionStore((s) => s.connections) const connections = useConnectionStore((s) => s.connections)
const removeConnection = useConnectionStore((s) => s.removeConnection) const removeConnection = useConnectionStore((s) => s.removeConnection)
const { addToast } = useToast() const { addToast } = useToast()
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<ConnectionConfig | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null) const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'connected': case 'connected':
return 'bg-green-500' return 'bg-success shadow-[0_0_6px] shadow-success/60'
case 'connecting': case 'connecting':
case 'failover': case 'failover':
return 'bg-yellow-500' return 'bg-warning'
case 'failed': case 'failed':
return 'bg-red-500' return 'bg-destructive'
default: default:
return 'bg-gray-500' return 'bg-muted-foreground/60'
} }
} }
const handleDelete = (id: string, name: string) => { const handleDelete = (id: string, name: string) => {
removeConnection(id) removeConnection(id)
setDeleteConfirmId(null)
addToast(`Connection "${name}" removed`, 'success') addToast(`Connection "${name}" removed`, 'success')
} }
const deleteTarget = connections.find((c) => c.id === deleteConfirmId) ?? null
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="font-mono text-sm tabular-nums text-muted-foreground">
{connections.length} connection{connections.length !== 1 ? 's' : ''} {connections.length} connection{connections.length !== 1 ? 's' : ''}
</p> </p>
<Button size="sm" onClick={() => setDialogOpen(true)}> <Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> <Plus />
Add Add
</Button> </Button>
</div> </div>
@@ -54,48 +58,28 @@ export function ConnectionManager() {
{connections.map((connection) => ( {connections.map((connection) => (
<div <div
key={connection.id} key={connection.id}
className="flex items-center justify-between rounded-lg border p-3" className="flex items-center justify-between rounded-md border border-border/70 bg-card px-3 py-2.5 transition-colors duration-150 hover:bg-accent/40"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={cn( className={cn(
'w-2.5 h-2.5 rounded-full', 'h-2 w-2 rounded-full',
getStatusColor(connection.status) getStatusColor(connection.status)
)} )}
/> />
<div> <div>
<p className="text-sm font-medium">{connection.name}</p> <p className="text-sm font-medium">{connection.name}</p>
<p className="text-xs text-muted-foreground"> <p className="font-mono text-xs tabular-nums text-muted-foreground">
{connection.primary.url} {connection.primary.url}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{deleteConfirmId === connection.id ? (
<>
<Button
size="sm"
variant="destructive"
onClick={() =>
handleDelete(connection.id, connection.name)
}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="h-8 w-8" className="h-8 w-8"
onClick={() => setEditing(connection)}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
@@ -107,15 +91,33 @@ export function ConnectionManager() {
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</>
)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
<ConnectionDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <ConnectionDialog
open={!!editing || dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setEditing(null)
}}
editing={editing ?? undefined}
/>
<ConfirmDialog
open={deleteConfirmId !== null}
onOpenChange={(open) => {
if (!open) setDeleteConfirmId(null)
}}
title="Remove Connection"
description={`Are you sure you want to remove "${deleteTarget?.name}"? This action cannot be undone.`}
confirmLabel="Remove"
onConfirm={() => {
if (deleteTarget) handleDelete(deleteTarget.id, deleteTarget.name)
}}
/>
</div> </div>
) )
} }
+2 -2
View File
@@ -53,11 +53,11 @@ export function SettingsPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Version</span> <span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-medium">0.1.0</span> <span className="font-mono text-sm font-medium tabular-nums">0.1.0</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Platform</span> <span className="text-sm text-muted-foreground">Platform</span>
<span className="text-sm font-medium">Tauri + React</span> <span className="font-mono text-sm font-medium">Tauri + React</span>
</div> </div>
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<a <a
+3 -3
View File
@@ -23,10 +23,10 @@ export function ThemeSwitcher() {
key={option.value} key={option.value}
onClick={() => setTheme(option.value)} onClick={() => setTheme(option.value)}
className={cn( className={cn(
'flex items-center gap-2 rounded-lg border px-4 py-3 text-sm font-medium transition-colors', 'flex items-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-colors duration-150',
theme === option.value theme === option.value
? 'border-primary bg-primary text-primary-foreground' ? 'border-primary/40 bg-primary/10 text-primary'
: 'border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground' : 'border-border bg-background text-muted-foreground hover:border-border hover:bg-accent hover:text-accent-foreground'
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
+10 -10
View File
@@ -9,9 +9,9 @@ interface StorageCardProps {
} }
function getUsageColor(percent: number): string { function getUsageColor(percent: number): string {
if (percent >= 90) return 'bg-red-500' if (percent >= 90) return 'bg-destructive'
if (percent >= 70) return 'bg-yellow-500' if (percent >= 70) return 'bg-warning'
return 'bg-green-500' return 'bg-success'
} }
function getStorageIcon(type: string) { function getStorageIcon(type: string) {
@@ -67,7 +67,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
return ( return (
<Card <Card
className="cursor-pointer hover:shadow-lg transition-shadow" className="cursor-pointer transition-all duration-150 hover:-translate-y-0.5 hover:shadow-card"
onClick={onClick} onClick={onClick}
> >
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -77,7 +77,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
</CardTitle> </CardTitle>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="uppercase bg-muted px-1.5 py-0.5 rounded">{storage.type}</span> <span className="uppercase bg-muted px-1.5 py-0.5 rounded">{storage.type}</span>
{storage.node && <span>{storage.node}</span>} {storage.node && <span className="font-mono">{storage.node}</span>}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@@ -85,22 +85,22 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Usage</span> <span className="text-muted-foreground">Usage</span>
<span className="font-medium">{percent.toFixed(1)}%</span> <span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
</div> </div>
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-2 bg-muted rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${usageColor}`} className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
style={{ width: `${Math.min(percent, 100)}%` }} style={{ width: `${Math.min(percent, 100)}%` }}
/> />
</div> </div>
</div> </div>
{/* Size Info */} {/* Size Info */}
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
<span>{formatBytes(storage.used)} used</span> <span>{formatBytes(storage.used)} used</span>
<span>{formatBytes(storage.total)} total</span> <span>{formatBytes(storage.total)} total</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(storage.avail)} available {formatBytes(storage.avail)} available
</div> </div>
@@ -112,7 +112,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
return ( return (
<span <span
key={type} key={type}
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" className="inline-flex items-center gap-1 rounded-sm border border-border/70 bg-muted px-2 py-0.5 text-xs text-muted-foreground"
> >
<TypeIcon className="h-3 w-3" /> <TypeIcon className="h-3 w-3" />
{getContentTypeLabel(type)} {getContentTypeLabel(type)}
+47 -33
View File
@@ -1,5 +1,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, HardDrive, Database, Archive, Disc, Box, Clock, File } from 'lucide-react' import { ArrowLeft, HardDrive, Database, Archive, Disc, Box, Clock, File } from 'lucide-react'
import { useStorageDetail, useStorageContent } from '@/hooks/useProxmox' import { useStorageDetail, useStorageContent } from '@/hooks/useProxmox'
import type { ProxmoxStorageContent } from '@/types/proxmox' import type { ProxmoxStorageContent } from '@/types/proxmox'
@@ -13,9 +15,9 @@ interface StorageDetailProps {
} }
function getUsageColor(percent: number): string { function getUsageColor(percent: number): string {
if (percent >= 90) return 'bg-red-500' if (percent >= 90) return 'bg-destructive'
if (percent >= 70) return 'bg-yellow-500' if (percent >= 70) return 'bg-warning'
return 'bg-green-500' return 'bg-success'
} }
function getStorageIcon(type: string) { function getStorageIcon(type: string) {
@@ -89,8 +91,19 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
if (detailLoading || contentLoading) { if (detailLoading || contentLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="h-full overflow-auto p-6">
<p className="text-muted-foreground">Loading storage details...</p> <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-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div> </div>
) )
} }
@@ -114,10 +127,11 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="h-6 w-6 text-muted-foreground" /> <Icon className="h-6 w-6 text-muted-foreground" />
<h2 className="text-2xl font-semibold">{detail.storage}</h2> <h2 className="font-mono text-2xl font-semibold tracking-tight">{detail.storage}</h2>
</div> </div>
<p className="text-muted-foreground"> <p className="text-sm text-muted-foreground">
{detail.type.toUpperCase()} storage on {detail.node} <span className="font-mono">{detail.type.toUpperCase()}</span> storage on{' '}
<span className="font-mono">{detail.node}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -132,15 +146,15 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Disk Usage</span> <span className="text-muted-foreground">Disk Usage</span>
<span className="font-medium">{percent.toFixed(1)}%</span> <span className="font-mono font-medium tabular-nums">{percent.toFixed(1)}%</span>
</div> </div>
<div className="h-4 bg-muted rounded-full overflow-hidden"> <div className="h-4 bg-muted rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all ${usageColor}`} className={`h-full rounded-full transition-all duration-500 ${usageColor}`}
style={{ width: `${Math.min(percent, 100)}%` }} style={{ width: `${Math.min(percent, 100)}%` }}
/> />
</div> </div>
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between font-mono text-xs tabular-nums text-muted-foreground">
<span>{formatBytes(detail.used)} used</span> <span>{formatBytes(detail.used)} used</span>
<span>{formatBytes(detail.total)} total</span> <span>{formatBytes(detail.total)} total</span>
</div> </div>
@@ -149,24 +163,24 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-2">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Total Size</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Size</p>
<p className="text-sm font-medium">{formatBytes(detail.total)}</p> <p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.total)}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Used</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Used</p>
<p className="text-sm font-medium">{formatBytes(detail.used)}</p> <p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.used)}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Available</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</p>
<p className="text-sm font-medium">{formatBytes(detail.avail)}</p> <p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.avail)}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Status</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</p>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{detail.active ? ( {detail.active ? (
<span className="text-green-600">Active</span> <span className="text-success">Active</span>
) : ( ) : (
<span className="text-gray-500">Inactive</span> <span className="text-muted-foreground">Inactive</span>
)} )}
</p> </p>
</div> </div>
@@ -180,29 +194,29 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
<CardTitle className="text-base">Storage Information</CardTitle> <CardTitle className="text-base">Storage Information</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-5">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Name</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</p>
<p className="text-sm font-medium">{detail.storage}</p> <p className="font-mono text-sm font-medium">{detail.storage}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Type</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</p>
<p className="text-sm font-medium uppercase">{detail.type}</p> <p className="font-mono text-sm font-medium uppercase">{detail.type}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Node</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</p>
<p className="text-sm font-medium">{detail.node}</p> <p className="font-mono text-sm font-medium">{detail.node}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Enabled</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Enabled</p>
<p className="text-sm font-medium">{detail.enabled ? 'Yes' : 'No'}</p> <p className="text-sm font-medium">{detail.enabled ? 'Yes' : 'No'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Shared</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Shared</p>
<p className="text-sm font-medium">{detail.shared ? 'Yes' : 'No'}</p> <p className="text-sm font-medium">{detail.shared ? 'Yes' : 'No'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground">Content Types</p> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Content Types</p>
<p className="text-sm font-medium">{detail.content || 'None'}</p> <p className="text-sm font-medium">{detail.content || 'None'}</p>
</div> </div>
</div> </div>
@@ -223,7 +237,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!contentList || contentList.length === 0 ? ( {!contentList || contentList.length === 0 ? (
<p className="text-sm text-muted-foreground">No content found</p> <EmptyState icon={File} title="No content found" />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(contentByType).map(([type, items]) => { {Object.entries(contentByType).map(([type, items]) => {
@@ -243,11 +257,11 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<File className="h-3.5 w-3.5 text-muted-foreground" /> <File className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate max-w-[300px]"> <span className="truncate max-w-[300px] font-mono text-xs">
{formatContentItem(item)} {formatContentItem(item)}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 font-mono text-xs tabular-nums text-muted-foreground">
{item.format && ( {item.format && (
<span className="uppercase bg-muted px-1.5 py-0.5 rounded"> <span className="uppercase bg-muted px-1.5 py-0.5 rounded">
{item.format} {item.format}
+14 -11
View File
@@ -1,6 +1,8 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useStorage } from '@/hooks/useProxmox' import { useStorage } from '@/hooks/useProxmox'
import { StorageCard } from './StorageCard' import { StorageCard } from './StorageCard'
import { EmptyState } from '@/components/ui/empty-state'
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
import { Search, HardDrive } from 'lucide-react' import { Search, HardDrive } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -27,9 +29,13 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <PageSkeleton filter>
<p className="text-muted-foreground">Loading storage pools...</p> <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> </div>
</PageSkeleton>
) )
} }
@@ -46,7 +52,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h2 className="text-2xl font-semibold">Storage Pools</h2> <h2 className="text-2xl font-semibold tracking-tight">Storage Pools</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{storageList?.length ?? 0} storage pools across the cluster {storageList?.length ?? 0} storage pools across the cluster
</p> </p>
@@ -65,14 +71,11 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
{/* Storage Grid */} {/* Storage Grid */}
{filteredStorage.length === 0 ? ( {filteredStorage.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> storageList?.length === 0 ? (
<HardDrive className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" /> <EmptyState icon={HardDrive} title="No storage pools found" />
<p> ) : (
{storageList?.length === 0 <EmptyState icon={Search} title="No storage pools match your search" />
? 'No storage pools found' )
: 'No storage pools match your search'}
</p>
</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredStorage.map((storage) => ( {filteredStorage.map((storage) => (
+49 -54
View File
@@ -1,9 +1,12 @@
import { useState, useMemo, Fragment } from 'react' import { useState, useMemo, Fragment } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Search, ListTodo, ChevronDown, ChevronRight, Play, CheckCircle, XCircle, Clock } from 'lucide-react' import { EmptyState } from '@/components/ui/empty-state'
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
import { Search, ListTodo, ChevronDown, ChevronRight, CheckCircle, XCircle, Clock, Loader2 } from 'lucide-react'
import { useTasks } from '@/hooks/useProxmox' import { useTasks } from '@/hooks/useProxmox'
import type { ProxmoxTask } from '@/types/proxmox' import type { ProxmoxTask } from '@/types/proxmox'
import { cn } from '@/lib/utils'
interface TaskListProps { interface TaskListProps {
connectionId: string connectionId: string
@@ -44,19 +47,19 @@ function getTaskStatus(task: ProxmoxTask): 'running' | 'completed' | 'failed' {
function TaskStatusIcon({ status }: { status: 'running' | 'completed' | 'failed' }) { function TaskStatusIcon({ status }: { status: 'running' | 'completed' | 'failed' }) {
switch (status) { switch (status) {
case 'running': case 'running':
return <Play className="h-3.5 w-3.5 text-blue-500" /> return <Loader2 className="h-3.5 w-3.5 text-info animate-spin" />
case 'completed': case 'completed':
return <CheckCircle className="h-3.5 w-3.5 text-green-500" /> return <CheckCircle className="h-3.5 w-3.5 text-success" />
case 'failed': case 'failed':
return <XCircle className="h-3.5 w-3.5 text-red-500" /> return <XCircle className="h-3.5 w-3.5 text-destructive" />
} }
} }
function TaskStatusBadge({ status }: { status: 'running' | 'completed' | 'failed' }) { function TaskStatusBadge({ status }: { status: 'running' | 'completed' | 'failed' }) {
const config = { const config = {
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', running: 'border-info/25 bg-info/10 text-info',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', completed: 'border-success/25 bg-success/10 text-success',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', failed: 'border-destructive/25 bg-destructive/10 text-destructive',
} }
const label = { const label = {
@@ -67,7 +70,7 @@ function TaskStatusBadge({ status }: { status: 'running' | 'completed' | 'failed
return ( return (
<span <span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config[status]}`} className={`inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium ${config[status]}`}
> >
<TaskStatusIcon status={status} /> <TaskStatusIcon status={status} />
{label[status]} {label[status]}
@@ -140,9 +143,9 @@ export function TaskList({ connectionId }: TaskListProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <PageSkeleton filter>
<p className="text-muted-foreground">Loading tasks...</p> <Skeleton className="h-96 w-full" />
</div> </PageSkeleton>
) )
} }
@@ -160,13 +163,13 @@ export function TaskList({ connectionId }: TaskListProps) {
{/* Header */} {/* Header */}
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ListTodo className="h-6 w-6" /> <ListTodo className="h-6 w-6 text-muted-foreground" />
<h2 className="text-2xl font-semibold">Tasks</h2> <h2 className="text-2xl font-semibold tracking-tight">Tasks</h2>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{tasks?.length ?? 0} total tasks {tasks?.length ?? 0} total tasks
{runningCount > 0 && ( {runningCount > 0 && (
<span className="ml-2 text-blue-500 font-medium"> <span className="ml-2 font-medium text-info">
({runningCount} running) ({runningCount} running)
</span> </span>
)} )}
@@ -227,32 +230,24 @@ export function TaskList({ connectionId }: TaskListProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{filteredTasks.length === 0 ? ( {filteredTasks.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> tasks?.length === 0 ? (
{tasks?.length === 0 ? ( <EmptyState icon={ListTodo} title="No tasks found" />
<div className="space-y-2">
<ListTodo className="h-8 w-8 mx-auto text-muted-foreground/50" />
<p>No tasks found</p>
</div>
) : ( ) : (
<div className="space-y-2"> <EmptyState icon={Search} title="No tasks match your filters" />
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" /> )
<p>No tasks match your filters</p>
</div>
)}
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-8" /> <th className="h-10 px-4 w-8 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground" />
<th className="h-10 px-4 text-left font-medium text-muted-foreground">UPID</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">UPID</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Type</th> <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 font-medium text-muted-foreground">Node</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">User</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">User</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Status</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Start Time</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Start Time</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Duration</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Duration</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -262,7 +257,7 @@ export function TaskList({ connectionId }: TaskListProps) {
return ( return (
<Fragment key={task.upid}> <Fragment key={task.upid}>
<tr <tr
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors cursor-pointer" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => toggleExpanded(task.upid)} onClick={() => toggleExpanded(task.upid)}
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -272,57 +267,57 @@ export function TaskList({ connectionId }: TaskListProps) {
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
</td> </td>
<td className="px-4 py-3 font-mono text-muted-foreground">{task.upid}</td> <td className="px-4 py-3 font-mono text-xs text-muted-foreground">{task.upid}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{task.type} {task.type}
</span> </span>
</td> </td>
<td className="px-4 py-3">{task.node}</td> <td className="px-4 py-3 font-mono">{task.node}</td>
<td className="px-4 py-3 text-muted-foreground">{task.user}</td> <td className="px-4 py-3 text-muted-foreground">{task.user}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<TaskStatusBadge status={taskStatus} /> <TaskStatusBadge status={taskStatus} />
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
{formatTimestamp(task.starttime)} {formatTimestamp(task.starttime)}
</div> </div>
</td> </td>
<td className="px-4 py-3 text-right text-muted-foreground"> <td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-muted-foreground">
{formatDuration(task.starttime, task.endtime)} {formatDuration(task.starttime, task.endtime)}
</td> </td>
</tr> </tr>
{isExpanded && ( {isExpanded && (
<tr key={`${task.upid}-details`} className="border-b bg-muted/20"> <tr key={`${task.upid}-details`} className="border-b bg-muted/40">
<td colSpan={8} className="px-4 py-3"> <td colSpan={8} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-4 text-sm">
<div> <div>
<span className="text-muted-foreground">UPID:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">UPID</span>
<p className="font-mono">{task.upid}</p> <p className="mt-0.5 font-mono text-xs">{task.upid}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Task ID:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Task ID</span>
<p className="font-mono">{task.id}</p> <p className="mt-0.5 font-mono text-xs">{task.id}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">PID:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">PID</span>
<p className="font-mono">{task.pid}</p> <p className="mt-0.5 font-mono text-xs tabular-nums">{task.pid}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">PStart:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">PStart</span>
<p className="font-mono">{task.pstart}</p> <p className="mt-0.5 font-mono text-xs tabular-nums">{task.pstart}</p>
</div> </div>
{task.endtime && ( {task.endtime && (
<div> <div>
<span className="text-muted-foreground">End Time:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">End Time</span>
<p>{formatTimestamp(task.endtime)}</p> <p className="mt-0.5 font-mono text-xs tabular-nums">{formatTimestamp(task.endtime)}</p>
</div> </div>
)} )}
{task.exitstatus && ( {task.exitstatus && (
<div> <div>
<span className="text-muted-foreground">Exit Status:</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Exit Status</span>
<p className={task.exitstatus === 'OK' ? 'text-green-500' : 'text-red-500'}> <p className={cn('mt-0.5 font-mono text-xs', task.exitstatus === 'OK' ? 'text-success' : 'text-destructive')}>
{task.exitstatus} {task.exitstatus}
</p> </p>
</div> </div>
+4 -4
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { ListTodo, Play } from 'lucide-react' import { ListTodo, Loader2 } from 'lucide-react'
import { useTasks } from '@/hooks/useProxmox' import { useTasks } from '@/hooks/useProxmox'
import type { ProxmoxTask } from '@/types/proxmox' import type { ProxmoxTask } from '@/types/proxmox'
@@ -27,20 +27,20 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground" className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors duration-150 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
> >
<ListTodo className="h-4 w-4" /> <ListTodo className="h-4 w-4" />
<span className="flex-1 text-left"> <span className="flex-1 text-left">
{runningCount > 0 ? ( {runningCount > 0 ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="text-blue-500 font-medium">{runningCount} task{runningCount !== 1 ? 's' : ''} running</span> <span className="font-medium text-info">{runningCount} task{runningCount !== 1 ? 's' : ''} running</span>
</span> </span>
) : ( ) : (
<span>No tasks running</span> <span>No tasks running</span>
)} )}
</span> </span>
{runningCount > 0 && ( {runningCount > 0 && (
<Play className="h-3 w-3 text-blue-500 animate-pulse" /> <Loader2 className="h-3 w-3 text-info animate-spin" />
)} )}
</button> </button>
) )
+11 -6
View File
@@ -4,15 +4,20 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition duration-150 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', default:
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 'bg-primary text-primary-foreground shadow-sm shadow-primary/25 hover:bg-primary/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', destructive:
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground', outline:
'border border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost:
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
+8 -5
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-xl border bg-card text-card-foreground shadow', 'rounded-lg border bg-card text-card-foreground shadow-card',
className className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)} className={cn('flex flex-col space-y-1.5 p-5', className)}
{...props} {...props}
/> />
)) ))
@@ -34,7 +34,10 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)} className={cn(
'font-semibold leading-none tracking-tight text-foreground',
className
)}
{...props} {...props}
/> />
)) ))
@@ -56,7 +59,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} /> <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
)) ))
CardContent.displayName = 'CardContent' CardContent.displayName = 'CardContent'
@@ -66,7 +69,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('flex items-center p-6 pt-0', className)} className={cn('flex items-center p-5 pt-0', className)}
{...props} {...props}
/> />
)) ))
+63
View File
@@ -0,0 +1,63 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
isLoading?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = true,
isLoading = false,
onConfirm,
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+3 -3
View File
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className className
)} )}
{...props} {...props}
@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
'text-lg font-semibold leading-none tracking-tight', 'text-base font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: ReactNode
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">{title}</p>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const labelVariants = cva( const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
) )
const Label = React.forwardRef< const Label = React.forwardRef<
+1 -1
View File
@@ -37,7 +37,7 @@ const ScrollBar = React.forwardRef<
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted-foreground/25 transition-colors hover:bg-muted-foreground/40" />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
)) ))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-secondary', className)} />
}
interface PageSkeletonProps {
/** Render a search/filter bar between the header and the content blocks */
filter?: boolean
/** Custom content blocks; defaults to a generic card layout */
children?: ReactNode
}
/**
* Page-level loading placeholder. Mirrors the standard layout of the
* main views (header line + optional filter bar + content blocks).
*/
export function PageSkeleton({ filter = false, children }: PageSkeletonProps) {
return (
<div className="h-full overflow-auto p-6">
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
{filter && (
<div className="flex flex-wrap gap-3">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-9 w-36" />
<Skeleton className="h-9 w-36" />
</div>
)}
{children ?? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-56 w-full" />
<Skeleton className="h-32 w-full" />
</>
)}
</div>
</div>
)
}
+9 -9
View File
@@ -1,5 +1,5 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CheckCircle, XCircle, Pause, Clock } from 'lucide-react' import { CheckCircle, Square, Pause, Clock, XCircle } from 'lucide-react'
type BadgeStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'online' | 'offline' type BadgeStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'online' | 'offline'
@@ -11,32 +11,32 @@ interface StatusBadgeProps {
const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = { const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = {
running: { running: {
label: 'Running', label: 'Running',
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', color: 'border-success/25 bg-success/10 text-success',
icon: CheckCircle, icon: CheckCircle,
}, },
stopped: { stopped: {
label: 'Stopped', label: 'Stopped',
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', color: 'border-border bg-muted/60 text-muted-foreground',
icon: XCircle, icon: Square,
}, },
paused: { paused: {
label: 'Paused', label: 'Paused',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', color: 'border-warning/25 bg-warning/10 text-warning',
icon: Pause, icon: Pause,
}, },
suspended: { suspended: {
label: 'Suspended', label: 'Suspended',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', color: 'border-warning/25 bg-warning/10 text-warning',
icon: Clock, icon: Clock,
}, },
online: { online: {
label: 'Online', label: 'Online',
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', color: 'border-success/25 bg-success/10 text-success',
icon: CheckCircle, icon: CheckCircle,
}, },
offline: { offline: {
label: 'Offline', label: 'Offline',
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', color: 'border-destructive/25 bg-destructive/10 text-destructive',
icon: XCircle, icon: XCircle,
}, },
} }
@@ -48,7 +48,7 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium', 'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
config.color, config.color,
className className
)} )}
+2 -2
View File
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground', 'inline-flex h-9 items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground',
className className
)} )}
{...props} {...props}
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow', 'inline-flex 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 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className className
)} )}
{...props} {...props}
+14 -10
View File
@@ -68,22 +68,26 @@ function ToastContainer({
) )
} }
const variantStyles: Record<ToastVariant, { icon: React.ComponentType<{ className?: string }>; container: string }> = { const variantStyles: Record<ToastVariant, { icon: React.ComponentType<{ className?: string }>; bar: string; iconColor: string }> = {
success: { success: {
icon: CheckCircle, icon: CheckCircle,
container: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-950 dark:border-green-800 dark:text-green-200', bar: 'border-l-success',
iconColor: 'text-success',
}, },
error: { error: {
icon: AlertCircle, icon: AlertCircle,
container: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200', bar: 'border-l-destructive',
iconColor: 'text-destructive',
}, },
warning: { warning: {
icon: AlertTriangle, icon: AlertTriangle,
container: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-200', bar: 'border-l-warning',
iconColor: 'text-warning',
}, },
info: { info: {
icon: Info, icon: Info,
container: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200', bar: 'border-l-info',
iconColor: 'text-info',
}, },
} }
@@ -100,18 +104,18 @@ function ToastItem({
return ( return (
<div <div
className={cn( className={cn(
'flex items-start gap-3 rounded-lg border p-4 shadow-lg', 'flex items-start gap-3 rounded-md border border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40',
config.container config.bar
)} )}
style={{ style={{
animation: 'toast-slide-in 0.3s ease-out', animation: 'toast-slide-in 0.3s ease-out',
}} }}
> >
<Icon className="h-5 w-5 mt-0.5 shrink-0" /> <Icon className={cn('h-5 w-5 mt-0.5 shrink-0', config.iconColor)} />
<p className="text-sm flex-1">{toast.message}</p> <p className="flex-1 text-sm">{toast.message}</p>
<button <button
onClick={() => onRemove(toast.id)} onClick={() => onRemove(toast.id)}
className="shrink-0 rounded-md p-0.5 opacity-70 hover:opacity-100 transition-opacity" className="shrink-0 rounded-md p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
+26 -32
View File
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { StatusBadge } from '@/components/ui/status-badge' import { StatusBadge } from '@/components/ui/status-badge'
import { EmptyState } from '@/components/ui/empty-state'
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
import { Search, Server, Box } from 'lucide-react' import { Search, Server, Box } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox' import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxVM } from '@/types/proxmox' import type { ProxmoxVM } from '@/types/proxmox'
@@ -49,9 +51,9 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <PageSkeleton filter>
<p className="text-muted-foreground">Loading containers...</p> <Skeleton className="h-96 w-full" />
</div> </PageSkeleton>
) )
} }
@@ -68,7 +70,7 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h2 className="text-2xl font-semibold">Containers</h2> <h2 className="text-2xl font-semibold tracking-tight">Containers</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{containers.length} total containers across the cluster {containers.length} total containers across the cluster
</p> </p>
@@ -116,44 +118,36 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{filteredContainers.length === 0 ? ( {filteredContainers.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> containers.length === 0 ? (
{containers.length === 0 ? ( <EmptyState icon={Box} title="No containers found" />
<div className="space-y-2">
<Box className="h-8 w-8 mx-auto text-muted-foreground/50" />
<p>No containers found</p>
</div>
) : ( ) : (
<div className="space-y-2"> <EmptyState icon={Search} title="No containers match your filters" />
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" /> )
<p>No containers match your filters</p>
</div>
)}
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">VMID</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">VMID</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Name</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Status</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Node</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">CPU</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">CPU</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Memory</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Memory</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Disk</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Disk</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Uptime</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredContainers.map((container) => ( {filteredContainers.map((container) => (
<tr <tr
key={`lxc-${container.vmid}`} key={`lxc-${container.vmid}`}
className={`border-b last:border-b-0 hover:bg-muted/50 transition-colors ${ className={`border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150 ${
onContainerClick ? 'cursor-pointer' : '' onContainerClick ? 'cursor-pointer' : ''
}`} }`}
onClick={() => onContainerClick?.(container)} onClick={() => onContainerClick?.(container)}
> >
<td className="px-4 py-3 font-mono text-muted-foreground">{container.vmid}</td> <td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">{container.vmid}</td>
<td className="px-4 py-3 font-medium">{container.name}</td> <td className="px-4 py-3 font-medium">{container.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<StatusBadge status={container.status} /> <StatusBadge status={container.status} />
@@ -161,21 +155,21 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5 text-muted-foreground" /> <Server className="h-3.5 w-3.5 text-muted-foreground" />
{container.node} <span className="font-mono">{container.node}</span>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-right font-mono"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{container.cpus} cores {container.cpus} cores
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{container.maxmem > 0 ? formatBytes(container.mem) : 'N/A'} {container.maxmem > 0 ? formatBytes(container.mem) : 'N/A'}
<span className="text-muted-foreground"> / {formatBytes(container.maxmem)}</span> <span className="text-muted-foreground"> / {formatBytes(container.maxmem)}</span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{container.maxdisk > 0 ? formatBytes(container.disk) : 'N/A'} {container.maxdisk > 0 ? formatBytes(container.disk) : 'N/A'}
<span className="text-muted-foreground"> / {formatBytes(container.maxdisk)}</span> <span className="text-muted-foreground"> / {formatBytes(container.maxdisk)}</span>
</td> </td>
<td className="px-4 py-3 text-right text-muted-foreground"> <td className="px-4 py-3 text-right font-mono tabular-nums text-muted-foreground">
{formatUptime(container.uptime)} {formatUptime(container.uptime)}
</td> </td>
</tr> </tr>
+18 -16
View File
@@ -61,7 +61,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1 const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1
const handleStart = () => { const handleStart = () => {
startVM.mutate({ node: vm.node, vmid: vm.vmid }) startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
} }
const handleStop = () => { const handleStop = () => {
@@ -89,11 +89,11 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
} }
const handleSuspend = () => { const handleSuspend = () => {
suspendVM.mutate({ node: vm.node, vmid: vm.vmid }) suspendVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
} }
const handleResume = () => { const handleResume = () => {
resumeVM.mutate({ node: vm.node, vmid: vm.vmid }) resumeVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
} }
const executeConfirmAction = () => { const executeConfirmAction = () => {
@@ -101,13 +101,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
switch (confirmAction.type) { switch (confirmAction.type) {
case 'stop': case 'stop':
stopVM.mutate({ node: vm.node, vmid: vm.vmid }) stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
break break
case 'shutdown': case 'shutdown':
shutdownVM.mutate({ node: vm.node, vmid: vm.vmid }) shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
break break
case 'reboot': case 'reboot':
rebootVM.mutate({ node: vm.node, vmid: vm.vmid }) rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
break break
} }
@@ -125,11 +125,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
</Button> </Button>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold">{vm.name}</h2> <h2 className="text-2xl font-semibold tracking-tight">{vm.name}</h2>
<StatusBadge status={vm.status} /> <StatusBadge status={vm.status} />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="mt-0.5 text-sm text-muted-foreground">
VMID {vm.vmid} &middot; {vm.type.toUpperCase()} &middot; {vm.node} VMID <span className="font-mono tabular-nums">{vm.vmid}</span> &middot;{' '}
<span className="font-mono">{vm.type.toUpperCase()}</span> &middot;{' '}
<span className="font-mono">{vm.node}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -142,7 +144,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleStart} onClick={handleStart}
disabled={isBusy} disabled={isBusy}
> >
<Play className="h-4 w-4 mr-1" /> <Play />
Start Start
</Button> </Button>
) : null} ) : null}
@@ -155,7 +157,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleShutdown} onClick={handleShutdown}
disabled={isBusy} disabled={isBusy}
> >
<Power className="h-4 w-4 mr-1" /> <Power />
Shutdown Shutdown
</Button> </Button>
<Button <Button
@@ -164,7 +166,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleStop} onClick={handleStop}
disabled={isBusy} disabled={isBusy}
> >
<Square className="h-4 w-4 mr-1" /> <Square />
Stop Stop
</Button> </Button>
<Button <Button
@@ -173,7 +175,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleReboot} onClick={handleReboot}
disabled={isBusy} disabled={isBusy}
> >
<RotateCw className="h-4 w-4 mr-1" /> <RotateCw />
Reboot Reboot
</Button> </Button>
</> </>
@@ -186,7 +188,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleSuspend} onClick={handleSuspend}
disabled={isBusy} disabled={isBusy}
> >
<Pause className="h-4 w-4 mr-1" /> <Pause />
Suspend Suspend
</Button> </Button>
)} )}
@@ -198,7 +200,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={handleResume} onClick={handleResume}
disabled={isBusy} disabled={isBusy}
> >
<PlayCircle className="h-4 w-4 mr-1" /> <PlayCircle />
Resume Resume
</Button> </Button>
)} )}
@@ -210,7 +212,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
onClick={() => setMigrateDialogOpen(true)} onClick={() => setMigrateDialogOpen(true)}
disabled={isBusy} disabled={isBusy}
> >
<ArrowRightLeft className="h-4 w-4 mr-1" /> <ArrowRightLeft />
Migrate Migrate
</Button> </Button>
)} )}
+27 -33
View File
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { StatusBadge } from '@/components/ui/status-badge' import { StatusBadge } from '@/components/ui/status-badge'
import { EmptyState } from '@/components/ui/empty-state'
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
import { Search, Server, Box } from 'lucide-react' import { Search, Server, Box } from 'lucide-react'
import { useVMs } from '@/hooks/useProxmox' import { useVMs } from '@/hooks/useProxmox'
import type { ProxmoxVM } from '@/types/proxmox' import type { ProxmoxVM } from '@/types/proxmox'
@@ -50,9 +52,9 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
if (vmsLoading) { if (vmsLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <PageSkeleton filter>
<p className="text-muted-foreground">Loading VMs...</p> <Skeleton className="h-96 w-full" />
</div> </PageSkeleton>
) )
} }
@@ -69,7 +71,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h2 className="text-2xl font-semibold">Virtual Machines</h2> <h2 className="text-2xl font-semibold tracking-tight">Virtual Machines</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{vms?.length ?? 0} total VMs across the cluster {vms?.length ?? 0} total VMs across the cluster
</p> </p>
@@ -127,45 +129,37 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{filteredVMs.length === 0 ? ( {filteredVMs.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> vms?.length === 0 ? (
{vms?.length === 0 ? ( <EmptyState icon={Box} title="No VMs found" />
<div className="space-y-2">
<Box className="h-8 w-8 mx-auto text-muted-foreground/50" />
<p>No VMs found</p>
</div>
) : ( ) : (
<div className="space-y-2"> <EmptyState icon={Search} title="No VMs match your filters" />
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" /> )
<p>No VMs match your filters</p>
</div>
)}
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">VMID</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">VMID</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Name</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Status</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Node</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Type</th> <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-right font-medium text-muted-foreground">CPU</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">CPU</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Memory</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Memory</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Disk</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Disk</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Uptime</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredVMs.map((vm) => ( {filteredVMs.map((vm) => (
<tr <tr
key={`${vm.type}-${vm.vmid}`} key={`${vm.type}-${vm.vmid}`}
className={`border-b last:border-b-0 hover:bg-muted/50 transition-colors ${ className={`border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150 ${
onVMClick ? 'cursor-pointer' : '' onVMClick ? 'cursor-pointer' : ''
}`} }`}
onClick={() => onVMClick?.(vm)} onClick={() => onVMClick?.(vm)}
> >
<td className="px-4 py-3 font-mono text-muted-foreground">{vm.vmid}</td> <td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">{vm.vmid}</td>
<td className="px-4 py-3 font-medium">{vm.name}</td> <td className="px-4 py-3 font-medium">{vm.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<StatusBadge status={vm.status} /> <StatusBadge status={vm.status} />
@@ -173,7 +167,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5 text-muted-foreground" /> <Server className="h-3.5 w-3.5 text-muted-foreground" />
{vm.node} <span className="font-mono">{vm.node}</span>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -181,18 +175,18 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
{vm.type} {vm.type}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right font-mono"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{vm.cpus} cores {vm.cpus} cores
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'} {vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'}
<span className="text-muted-foreground"> / {formatBytes(vm.maxmem)}</span> <span className="text-muted-foreground"> / {formatBytes(vm.maxmem)}</span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right font-mono tabular-nums">
{vm.maxdisk > 0 ? formatBytes(vm.disk) : 'N/A'} {vm.maxdisk > 0 ? formatBytes(vm.disk) : 'N/A'}
<span className="text-muted-foreground"> / {formatBytes(vm.maxdisk)}</span> <span className="text-muted-foreground"> / {formatBytes(vm.maxdisk)}</span>
</td> </td>
<td className="px-4 py-3 text-right text-muted-foreground"> <td className="px-4 py-3 text-right font-mono tabular-nums text-muted-foreground">
{formatUptime(vm.uptime)} {formatUptime(vm.uptime)}
</td> </td>
</tr> </tr>
@@ -32,6 +32,7 @@ export function AddDiskDialog({ open, onOpenChange, vm }: AddDiskDialogProps) {
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
config: { storage, size: sizeBytes, busType }, config: { storage, size: sizeBytes, busType },
}, },
{ {
+2 -1
View File
@@ -33,6 +33,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
config: { config: {
bridge, bridge,
model, model,
@@ -114,7 +115,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
type="checkbox" type="checkbox"
checked={firewall} checked={firewall}
onChange={(e) => setFirewall(e.target.checked)} onChange={(e) => setFirewall(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="firewall" className="cursor-pointer"> <Label htmlFor="firewall" className="cursor-pointer">
Enable firewall Enable firewall
@@ -31,6 +31,7 @@ export function CreateSnapshotDialog({ open, onOpenChange, vm }: CreateSnapshotD
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
config: { config: {
name, name,
description: description || undefined, description: description || undefined,
@@ -83,7 +84,7 @@ export function CreateSnapshotDialog({ open, onOpenChange, vm }: CreateSnapshotD
id="vmstate" id="vmstate"
checked={vmstate} checked={vmstate}
onChange={(e) => setVmstate(e.target.checked)} onChange={(e) => setVmstate(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="vmstate" className="text-sm font-normal cursor-pointer"> <Label htmlFor="vmstate" className="text-sm font-normal cursor-pointer">
Include VM state (memory) Include VM state (memory)
+2 -1
View File
@@ -33,6 +33,7 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
nic: nic.name, nic: nic.name,
config: { config: {
bridge: bridge || undefined, bridge: bridge || undefined,
@@ -100,7 +101,7 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
type="checkbox" type="checkbox"
checked={firewall} checked={firewall}
onChange={(e) => setFirewall(e.target.checked)} onChange={(e) => setFirewall(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="edit-firewall" className="cursor-pointer"> <Label htmlFor="edit-firewall" className="cursor-pointer">
Enable firewall Enable firewall
+5 -4
View File
@@ -37,6 +37,7 @@ export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateD
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
targetNode, targetNode,
online, online,
}, },
@@ -60,10 +61,10 @@ export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateD
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-md bg-muted p-3 flex gap-2"> <div className="flex gap-2 rounded-md border border-warning/25 bg-warning/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 shrink-0" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-yellow-600">Warning</p> <p className="font-medium text-warning">Warning</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Migration may cause temporary downtime. Online migration keeps the VM running but Migration may cause temporary downtime. Online migration keeps the VM running but
requires shared storage. Offline migration requires the VM to be stopped. requires shared storage. Offline migration requires the VM to be stopped.
@@ -103,7 +104,7 @@ export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateD
id="online" id="online"
checked={online} checked={online}
onChange={(e) => setOnline(e.target.checked)} onChange={(e) => setOnline(e.target.checked)}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input accent-primary"
/> />
<Label htmlFor="online" className="text-sm font-normal cursor-pointer"> <Label htmlFor="online" className="text-sm font-normal cursor-pointer">
Online migration (VM stays running) Online migration (VM stays running)
@@ -30,6 +30,7 @@ export function MoveDiskDialog({ open, onOpenChange, vm, disk }: MoveDiskDialogP
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
disk: disk.device, disk: disk.device,
storage: targetStorage, storage: targetStorage,
}, },
@@ -33,6 +33,7 @@ export function ResizeDiskDialog({ open, onOpenChange, vm, disk }: ResizeDiskDia
{ {
node: vm.node, node: vm.node,
vmid: vm.vmid, vmid: vm.vmid,
vmType: vm.type,
disk: disk.device, disk: disk.device,
size: sizeBytes, size: sizeBytes,
}, },
+6 -6
View File
@@ -85,7 +85,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
return ( return (
<div className="flex flex-col h-[600px]"> <div className="flex flex-col h-[600px]">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between border rounded-t-lg bg-muted/30 px-3 py-2"> <div className="flex items-center justify-between rounded-t-lg border border-b-0 border-border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isVM ? ( {isVM ? (
<Monitor className="h-4 w-4 text-muted-foreground" /> <Monitor className="h-4 w-4 text-muted-foreground" />
@@ -95,7 +95,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{isVM ? 'VNC Console' : 'Terminal Console'} {isVM ? 'VNC Console' : 'Terminal Console'}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
({vm.name} - {vm.type.toUpperCase()}) ({vm.name} - {vm.type.toUpperCase()})
</span> </span>
</div> </div>
@@ -149,7 +149,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
onClick={handleRetry} onClick={handleRetry}
className="h-7 text-xs" className="h-7 text-xs"
> >
<RotateCw className="h-3 w-3 mr-1" /> <RotateCw />
Retry Retry
</Button> </Button>
) : ( ) : (
@@ -168,7 +168,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
{/* Console Area */} {/* Console Area */}
<div <div
ref={containerRef} ref={containerRef}
className="flex-1 border border-t-0 rounded-b-lg overflow-hidden bg-black relative" className="relative flex-1 overflow-hidden rounded-b-lg border border-t-0 border-border bg-black"
> >
{/* Idle state - show placeholder */} {/* Idle state - show placeholder */}
{status === 'idle' && ( {status === 'idle' && (
@@ -183,7 +183,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
{isVM ? 'VNC Console' : 'Terminal Console'} {isVM ? 'VNC Console' : 'Terminal Console'}
</p> </p>
{!canConnect ? ( {!canConnect ? (
<p className="text-xs text-amber-500"> <p className="text-xs text-warning">
VM must be running to access console VM must be running to access console
</p> </p>
) : ( ) : (
@@ -214,7 +214,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
onClick={handleRetry} onClick={handleRetry}
className="mt-2" className="mt-2"
> >
<RotateCw className="h-3 w-3 mr-1" /> <RotateCw />
Retry Connection Retry Connection
</Button> </Button>
</div> </div>
+38 -57
View File
@@ -1,14 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { ConfirmDialog } from '@/components/ui/confirm-dialog'
Dialog, import { EmptyState } from '@/components/ui/empty-state'
DialogContent, import { Skeleton } from '@/components/ui/skeleton'
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { HardDrive, Plus, Pencil, Trash, ArrowRightLeft } from 'lucide-react' import { HardDrive, Plus, Pencil, Trash, ArrowRightLeft } from 'lucide-react'
import { useDisks, useRemoveDisk } from '@/hooks/useProxmox' import { useDisks, useRemoveDisk } from '@/hooks/useProxmox'
import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog' import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog'
@@ -23,7 +18,7 @@ interface DisksTabProps {
} }
export function DisksTab({ vm, connectionId }: DisksTabProps) { export function DisksTab({ vm, connectionId }: DisksTabProps) {
const { data: disks, isLoading, error } = useDisks(connectionId, vm.node, vm.vmid) const { data: disks, isLoading, error } = useDisks(connectionId, vm.node, vm.vmid, vm.type)
const removeDisk = useRemoveDisk() const removeDisk = useRemoveDisk()
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
@@ -33,20 +28,20 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
const handleDelete = () => { const handleDelete = () => {
if (!deleteDisk) return if (!deleteDisk) return
removeDisk.mutate( removeDisk.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device })
{ node: vm.node, vmid: vm.vmid, disk: deleteDisk.device },
{
onSuccess: () => {
setDeleteDisk(null)
},
},
)
} }
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="space-y-4">
<p className="text-muted-foreground">Loading disks...</p> <div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-8 w-28" />
</div>
<Skeleton className="h-64 w-full" />
</div> </div>
) )
} }
@@ -65,7 +60,7 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-medium">Disks</h3> <h3 className="text-lg font-semibold tracking-tight">Disks</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{diskList.length} disk{diskList.length !== 1 ? 's' : ''} attached {diskList.length} disk{diskList.length !== 1 ? 's' : ''} attached
</p> </p>
@@ -79,38 +74,35 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{diskList.length === 0 ? ( {diskList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <EmptyState icon={HardDrive} title="No disks attached" />
<HardDrive className="h-8 w-8 mb-2 opacity-50" />
<p>No disks attached</p>
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Device</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Device</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Size</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Size</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Storage</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Storage</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Format</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Format</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Usage</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Usage</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{diskList.map((disk) => ( {diskList.map((disk) => (
<tr <tr
key={disk.device} key={disk.device}
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
> >
<td className="px-4 py-3 font-mono">{disk.device}</td> <td className="px-4 py-3 font-mono">{disk.device}</td>
<td className="px-4 py-3">{formatBytes(disk.size)}</td> <td className="px-4 py-3 font-mono tabular-nums">{formatBytes(disk.size)}</td>
<td className="px-4 py-3">{disk.storage}</td> <td className="px-4 py-3 font-mono">{disk.storage}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{disk.format} {disk.format}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">
{disk.usage ?? '-'} {disk.usage ?? '-'}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -183,28 +175,17 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={!!deleteDisk} onOpenChange={(open) => { if (!open) setDeleteDisk(null) }}> <ConfirmDialog
<DialogContent> open={!!deleteDisk}
<DialogHeader> onOpenChange={(open) => {
<DialogTitle>Remove Disk</DialogTitle> if (!open) setDeleteDisk(null)
<DialogDescription> }}
Are you sure you want to remove {deleteDisk?.device} from {vm.name}? This action cannot be undone. title="Remove Disk"
</DialogDescription> description={`Are you sure you want to remove ${deleteDisk?.device} from ${vm.name}? This action cannot be undone.`}
</DialogHeader> confirmLabel="Remove"
<DialogFooter> isLoading={removeDisk.isPending}
<Button variant="outline" onClick={() => setDeleteDisk(null)}> onConfirm={handleDelete}
Cancel />
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={removeDisk.isPending}
>
{removeDisk.isPending ? 'Removing...' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+10 -10
View File
@@ -42,7 +42,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Basic Configuration</CardTitle> <CardTitle className="text-base">Basic Configuration</CardTitle>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}> <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-1" /> <Pencil />
Edit Edit
</Button> </Button>
</CardHeader> </CardHeader>
@@ -54,11 +54,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">VMID</p> <p className="text-sm font-medium text-muted-foreground">VMID</p>
<p className="text-sm font-mono">{vm.vmid}</p> <p className="font-mono text-sm tabular-nums">{vm.vmid}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Type</p> <p className="text-sm font-medium text-muted-foreground">Type</p>
<p className="text-sm uppercase">{vm.type}</p> <p className="font-mono text-sm uppercase">{vm.type}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Tags</p> <p className="text-sm font-medium text-muted-foreground">Tags</p>
@@ -74,18 +74,18 @@ export function HardwareTab({ vm }: HardwareTabProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-3 p-3 rounded-lg border"> <div className="flex items-center gap-3 rounded-md border border-border/70 bg-muted/30 px-3 py-2.5">
<Cpu className="h-5 w-5 text-muted-foreground" /> <Cpu className="h-5 w-5 text-muted-foreground" />
<div> <div>
<p className="text-sm font-medium">CPU Cores</p> <p className="text-sm font-medium">CPU Cores</p>
<p className="text-sm text-muted-foreground">{vm.cpus} cores</p> <p className="font-mono text-sm tabular-nums text-muted-foreground">{vm.cpus} cores</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 p-3 rounded-lg border"> <div className="flex items-center gap-3 rounded-md border border-border/70 bg-muted/30 px-3 py-2.5">
<MemoryStick className="h-5 w-5 text-muted-foreground" /> <MemoryStick className="h-5 w-5 text-muted-foreground" />
<div> <div>
<p className="text-sm font-medium">Memory</p> <p className="text-sm font-medium">Memory</p>
<p className="text-sm text-muted-foreground"> <p className="font-mono text-sm tabular-nums text-muted-foreground">
{formatBytes(vm.maxmem)} {formatBytes(vm.maxmem)}
</p> </p>
</div> </div>
@@ -102,7 +102,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">Boot Order</p> <p className="text-sm font-medium text-muted-foreground">Boot Order</p>
<p className="text-sm">scsi0, net0</p> <p className="font-mono text-sm">scsi0, net0</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">BIOS</p> <p className="text-sm font-medium text-muted-foreground">BIOS</p>
@@ -166,11 +166,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}> <Button variant="outline" onClick={() => setEditOpen(false)}>
<X className="h-4 w-4 mr-1" /> <X />
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={handleSave}>
<Save className="h-4 w-4 mr-1" /> <Save />
Save Changes Save Changes
</Button> </Button>
</DialogFooter> </DialogFooter>
+41 -60
View File
@@ -1,14 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { ConfirmDialog } from '@/components/ui/confirm-dialog'
Dialog, import { EmptyState } from '@/components/ui/empty-state'
DialogContent, import { Skeleton } from '@/components/ui/skeleton'
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Network, Plus, Pencil, Trash } from 'lucide-react' import { Network, Plus, Pencil, Trash } from 'lucide-react'
import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox' import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox'
import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog' import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog'
@@ -22,7 +17,7 @@ interface NetworkTabProps {
} }
export function NetworkTab({ vm, connectionId }: NetworkTabProps) { export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid) const { data: interfaces, isLoading, error } = useNetworkInterfaces(connectionId, vm.node, vm.vmid, vm.type)
const removeNIC = useRemoveNIC() const removeNIC = useRemoveNIC()
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
@@ -31,20 +26,20 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
const handleDelete = () => { const handleDelete = () => {
if (!deleteNic) return if (!deleteNic) return
removeNIC.mutate( removeNIC.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name })
{ node: vm.node, vmid: vm.vmid, nic: deleteNic.name },
{
onSuccess: () => {
setDeleteNic(null)
},
},
)
} }
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="space-y-4">
<p className="text-muted-foreground">Loading network interfaces...</p> <div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-8 w-28" />
</div>
<Skeleton className="h-64 w-full" />
</div> </div>
) )
} }
@@ -63,10 +58,10 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-medium">Network Interfaces</h3> <h3 className="text-lg font-semibold tracking-tight">Network Interfaces</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{nicList.length} NIC{nicList.length !== 1 ? 's' : ''} attached {nicList.length} NIC{nicList.length !== 1 ? 's' : ''} attached
<span className="ml-2 text-xs"> <span className="ml-2 font-mono text-xs tabular-nums">
I/O: {formatNetworkRate(vm.netin)} in / {formatNetworkRate(vm.netout)} out I/O: {formatNetworkRate(vm.netin)} in / {formatNetworkRate(vm.netout)} out
</span> </span>
</p> </p>
@@ -80,29 +75,26 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{nicList.length === 0 ? ( {nicList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <EmptyState icon={Network} title="No network interfaces attached" />
<Network className="h-8 w-8 mb-2 opacity-50" />
<p>No network interfaces attached</p>
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Device</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Device</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Model</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Model</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">MAC Address</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">MAC Address</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Bridge</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Bridge</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">VLAN</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">VLAN</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Firewall</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Firewall</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{nicList.map((nic) => ( {nicList.map((nic) => (
<tr <tr
key={nic.name} key={nic.name}
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
> >
<td className="px-4 py-3 font-mono">{nic.name}</td> <td className="px-4 py-3 font-mono">{nic.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -110,18 +102,18 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
{nic.model} {nic.model}
</span> </span>
</td> </td>
<td className="px-4 py-3 font-mono text-xs">{nic.macaddr}</td> <td className="px-4 py-3 font-mono text-xs tabular-nums">{nic.macaddr}</td>
<td className="px-4 py-3">{nic.bridge ?? '-'}</td> <td className="px-4 py-3 font-mono">{nic.bridge ?? '-'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{nic.tag != null ? ( {nic.tag != null ? (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">{nic.tag}</span> <span className="font-mono text-xs tabular-nums bg-muted px-1.5 py-0.5 rounded">{nic.tag}</span>
) : ( ) : (
'-' '-'
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{nic.firewall === 1 ? ( {nic.firewall === 1 ? (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" title="Enabled" /> <span className="inline-block h-2 w-2 rounded-full bg-success" title="Enabled" />
) : ( ) : (
<span className="inline-block h-2 w-2 rounded-full bg-muted-foreground/30" title="Disabled" /> <span className="inline-block h-2 w-2 rounded-full bg-muted-foreground/30" title="Disabled" />
)} )}
@@ -176,28 +168,17 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={!!deleteNic} onOpenChange={(open) => { if (!open) setDeleteNic(null) }}> <ConfirmDialog
<DialogContent> open={!!deleteNic}
<DialogHeader> onOpenChange={(open) => {
<DialogTitle>Remove Network Interface</DialogTitle> if (!open) setDeleteNic(null)
<DialogDescription> }}
Are you sure you want to remove {deleteNic?.name} from {vm.name}? This action cannot be undone. title="Remove Network Interface"
</DialogDescription> description={`Are you sure you want to remove ${deleteNic?.name} from ${vm.name}? This action cannot be undone.`}
</DialogHeader> confirmLabel="Remove"
<DialogFooter> isLoading={removeNIC.isPending}
<Button variant="outline" onClick={() => setDeleteNic(null)}> onConfirm={handleDelete}
Cancel />
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={removeNIC.isPending}
>
{removeNIC.isPending ? 'Removing...' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }
+27 -20
View File
@@ -18,6 +18,11 @@ function ResourceBar({ label, used, total, icon: Icon }: {
const displayUsed = label === 'CPU' ? used.toFixed(1) : formatBytes(used) const displayUsed = label === 'CPU' ? used.toFixed(1) : formatBytes(used)
const displayTotal = label === 'CPU' ? `${total} cores` : formatBytes(total) const displayTotal = label === 'CPU' ? `${total} cores` : formatBytes(total)
const barColor =
percent > 90 ? 'bg-destructive' :
percent > 70 ? 'bg-warning' :
'bg-success'
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -25,25 +30,17 @@ function ResourceBar({ label, used, total, icon: Icon }: {
<Icon className="h-4 w-4 text-muted-foreground" /> <Icon className="h-4 w-4 text-muted-foreground" />
{label} {label}
</div> </div>
<span className="text-sm text-muted-foreground"> <span className="font-mono text-sm tabular-nums text-muted-foreground">
{displayUsed} / {displayTotal} {displayUsed} / {displayTotal}
</span> </span>
</div> </div>
<div className="h-2 rounded-full bg-muted overflow-hidden"> <div className="h-2 rounded-full bg-muted overflow-hidden">
<div <div
className="h-full rounded-full transition-all duration-500" className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ style={{ width: `${Math.min(percent, 100)}%` }}
width: `${Math.min(percent, 100)}%`,
backgroundColor:
percent > 90
? 'hsl(var(--destructive))'
: percent > 70
? 'hsl(38, 92%, 50%)'
: 'hsl(142, 71%, 45%)',
}}
/> />
</div> </div>
<p className="text-xs text-muted-foreground text-right"> <p className="text-right font-mono text-xs tabular-nums text-muted-foreground">
{percent.toFixed(1)}% {percent.toFixed(1)}%
</p> </p>
</div> </div>
@@ -88,7 +85,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<Tag className="h-4 w-4 text-muted-foreground" /> <Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold font-mono">{vm.vmid}</div> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{vm.vmid}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -98,7 +97,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold uppercase">{vm.type}</div> <div className="font-mono text-2xl font-semibold leading-none tracking-tight uppercase">
{vm.type}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -108,7 +109,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatUptime(vm.uptime)}</div> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{formatUptime(vm.uptime)}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -118,7 +121,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{vm.node}</div> <div className="font-mono text-2xl font-semibold leading-none tracking-tight">
{vm.node}
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -128,8 +133,10 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<Globe className="h-4 w-4 text-muted-foreground" /> <Globe className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">192.168.1.{100 + (vm.vmid % 100)}</div> <div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
<p className="text-xs text-muted-foreground">Mock data</p> 192.168.1.{100 + (vm.vmid % 100)}
</div>
<p className="mt-1 text-xs text-muted-foreground">Mock data</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -142,11 +149,11 @@ export function OverviewTab({ vm }: OverviewTabProps) {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">In:</span> <span className="text-muted-foreground">In:</span>
<span className="font-mono">{formatNetworkRate(vm.netin)}</span> <span className="font-mono tabular-nums">{formatNetworkRate(vm.netin)}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Out:</span> <span className="text-muted-foreground">Out:</span>
<span className="font-mono">{formatNetworkRate(vm.netout)}</span> <span className="font-mono tabular-nums">{formatNetworkRate(vm.netout)}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+45 -53
View File
@@ -9,6 +9,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { Camera, Plus, Trash, RotateCcw } from 'lucide-react' import { Camera, Plus, Trash, RotateCcw } from 'lucide-react'
import { useSnapshots, useDeleteSnapshot, useRollbackSnapshot } from '@/hooks/useProxmox' import { useSnapshots, useDeleteSnapshot, useRollbackSnapshot } from '@/hooks/useProxmox'
import { CreateSnapshotDialog } from '@/components/vms/dialogs/CreateSnapshotDialog' import { CreateSnapshotDialog } from '@/components/vms/dialogs/CreateSnapshotDialog'
@@ -24,7 +27,7 @@ function formatDate(timestamp: number): string {
} }
export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) { export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
const { data: snapshots, isLoading, error } = useSnapshots(connectionId, vm.node, vm.vmid) const { data: snapshots, isLoading, error } = useSnapshots(connectionId, vm.node, vm.vmid, vm.type)
const deleteSnapshot = useDeleteSnapshot() const deleteSnapshot = useDeleteSnapshot()
const rollbackSnapshot = useRollbackSnapshot() const rollbackSnapshot = useRollbackSnapshot()
@@ -34,20 +37,13 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
const handleDelete = () => { const handleDelete = () => {
if (!deleteTarget) return if (!deleteTarget) return
deleteSnapshot.mutate( deleteSnapshot.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name })
{ node: vm.node, vmid: vm.vmid, name: deleteTarget.name },
{
onSuccess: () => {
setDeleteTarget(null)
},
},
)
} }
const handleRollback = () => { const handleRollback = () => {
if (!rollbackTarget) return if (!rollbackTarget) return
rollbackSnapshot.mutate( rollbackSnapshot.mutate(
{ node: vm.node, vmid: vm.vmid, name: rollbackTarget.name }, { node: vm.node, vmid: vm.vmid, vmType: vm.type, name: rollbackTarget.name },
{ {
onSuccess: () => { onSuccess: () => {
setRollbackTarget(null) setRollbackTarget(null)
@@ -58,8 +54,15 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="space-y-4">
<p className="text-muted-foreground">Loading snapshots...</p> <div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-8 w-36" />
</div>
<Skeleton className="h-64 w-full" />
</div> </div>
) )
} }
@@ -78,7 +81,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-medium">Snapshots</h3> <h3 className="text-lg font-semibold tracking-tight">Snapshots</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{snapshotList.length} snapshot{snapshotList.length !== 1 ? 's' : ''} {snapshotList.length} snapshot{snapshotList.length !== 1 ? 's' : ''}
</p> </p>
@@ -92,43 +95,43 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{snapshotList.length === 0 ? ( {snapshotList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <EmptyState
<Camera className="h-8 w-8 mb-2 opacity-50" /> icon={Camera}
<p>No snapshots</p> title="No snapshots"
<p className="text-xs mt-1">Create a snapshot to save the current VM state</p> description="Create a snapshot to save the current VM state"
</div> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <tr className="border-b">
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Name</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Description</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Description</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Created</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Created</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">VM State</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">VM State</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">Parent</th> <th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Parent</th>
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th> <th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{snapshotList.map((snapshot) => ( {snapshotList.map((snapshot) => (
<tr <tr
key={snapshot.name} key={snapshot.name}
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors" className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
> >
<td className="px-4 py-3 font-mono font-medium">{snapshot.name}</td> <td className="px-4 py-3 font-mono font-medium">{snapshot.name}</td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 text-muted-foreground">
{snapshot.description || '-'} {snapshot.description || '-'}
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
{formatDate(snapshot.snaptime)} {formatDate(snapshot.snaptime)}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={`text-xs px-1.5 py-0.5 rounded ${ className={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-xs font-medium ${
snapshot.vmstate === 1 snapshot.vmstate === 1
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'border-success/25 bg-success/10 text-success'
: 'bg-muted text-muted-foreground' : 'border-border bg-muted/60 text-muted-foreground'
}`} }`}
> >
{snapshot.vmstate === 1 ? 'Included' : 'Not included'} {snapshot.vmstate === 1 ? 'Included' : 'Not included'}
@@ -176,28 +179,17 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
/> />
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}> <ConfirmDialog
<DialogContent> open={!!deleteTarget}
<DialogHeader> onOpenChange={(open) => {
<DialogTitle>Delete Snapshot</DialogTitle> if (!open) setDeleteTarget(null)
<DialogDescription> }}
Are you sure you want to delete snapshot &quot;{deleteTarget?.name}&quot;? This action cannot be undone. title="Delete Snapshot"
</DialogDescription> description={`Are you sure you want to delete snapshot "${deleteTarget?.name}"? This action cannot be undone.`}
</DialogHeader> confirmLabel="Delete"
<DialogFooter> isLoading={deleteSnapshot.isPending}
<Button variant="outline" onClick={() => setDeleteTarget(null)}> onConfirm={handleDelete}
Cancel />
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteSnapshot.isPending}
>
{deleteSnapshot.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rollback confirmation */} {/* Rollback confirmation */}
<Dialog open={!!rollbackTarget} onOpenChange={(open) => { if (!open) setRollbackTarget(null) }}> <Dialog open={!!rollbackTarget} onOpenChange={(open) => { if (!open) setRollbackTarget(null) }}>
@@ -207,7 +199,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
<DialogDescription> <DialogDescription>
Are you sure you want to rollback {vm.name} to snapshot &quot;{rollbackTarget?.name}&quot;? Are you sure you want to rollback {vm.name} to snapshot &quot;{rollbackTarget?.name}&quot;?
{vm.status === 'running' && ( {vm.status === 'running' && (
<span className="block mt-1 text-yellow-600 font-medium"> <span className="block mt-1 font-medium text-warning">
The VM will be stopped during the rollback process. The VM will be stopped during the rollback process.
</span> </span>
)} )}
+74 -29
View File
@@ -92,11 +92,12 @@ export const useStartVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.startVM(connId, node, vmid) return api.startVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM started', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -113,11 +114,12 @@ export const useStopVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.stopVM(connId, node, vmid) return api.stopVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM stopped', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -134,11 +136,12 @@ export const useShutdownVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.shutdownVM(connId, node, vmid) return api.shutdownVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM shut down', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -155,11 +158,12 @@ export const useRebootVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.rebootVM(connId, node, vmid) return api.rebootVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM rebooting', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -176,11 +180,12 @@ export const useSuspendVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.suspendVM(connId, node, vmid) return api.suspendVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM suspended', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -197,11 +202,12 @@ export const useResumeVM = () => {
const { addToast } = useToast() const { addToast } = useToast()
return useMutation({ return useMutation({
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => { mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.resumeVM(connId, node, vmid) return api.resumeVM(connId, node, vmid, vmType)
}, },
onSuccess: () => { onSuccess: () => {
addToast('VM resumed', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -214,10 +220,10 @@ export const useResumeVM = () => {
} }
// Disk management hooks // Disk management hooks
export const useDisks = (connectionId: string, node: string, vmid: number) => { export const useDisks = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({ return useQuery({
queryKey: queryKeys.disks(connectionId, node, vmid), queryKey: queryKeys.disks(connectionId, node, vmid),
queryFn: () => api.getDisks(connectionId, node, vmid), queryFn: () => api.getDisks(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0, enabled: !!connectionId && !!node && vmid > 0,
}) })
} }
@@ -230,16 +236,19 @@ export const useAddDisk = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
config, config,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
config: AddDiskConfig config: AddDiskConfig
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.addDisk(connId, node, vmid, config) return api.addDisk(connId, node, vmid, vmType, config)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Disk added', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -261,18 +270,21 @@ export const useResizeDisk = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
disk, disk,
size, size,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
disk: string disk: string
size: number size: number
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.resizeDisk(connId, node, vmid, disk, size) return api.resizeDisk(connId, node, vmid, vmType, disk, size)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Disk resized', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -294,16 +306,19 @@ export const useRemoveDisk = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
disk, disk,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
disk: string disk: string
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.removeDisk(connId, node, vmid, disk) return api.removeDisk(connId, node, vmid, vmType, disk)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Disk removed', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -325,18 +340,21 @@ export const useMoveDisk = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
disk, disk,
storage, storage,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
disk: string disk: string
storage: string storage: string
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.moveDisk(connId, node, vmid, disk, storage) return api.moveDisk(connId, node, vmid, vmType, disk, storage)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Disk moved', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -351,10 +369,10 @@ export const useMoveDisk = () => {
} }
// Network management hooks // Network management hooks
export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number) => { export const useNetworkInterfaces = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({ return useQuery({
queryKey: queryKeys.networks(connectionId, node, vmid), queryKey: queryKeys.networks(connectionId, node, vmid),
queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid), queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0, enabled: !!connectionId && !!node && vmid > 0,
}) })
} }
@@ -367,16 +385,19 @@ export const useAddNIC = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
config, config,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
config: AddNICConfig config: AddNICConfig
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.addNIC(connId, node, vmid, config) return api.addNIC(connId, node, vmid, vmType, config)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Network interface added', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -398,18 +419,21 @@ export const useEditNIC = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
nic, nic,
config, config,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
nic: string nic: string
config: EditNICConfig config: EditNICConfig
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.editNIC(connId, node, vmid, nic, config) return api.editNIC(connId, node, vmid, vmType, nic, config)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Network interface updated', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -431,16 +455,19 @@ export const useRemoveNIC = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
nic, nic,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
nic: string nic: string
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.removeNIC(connId, node, vmid, nic) return api.removeNIC(connId, node, vmid, vmType, nic)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Network interface removed', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -455,10 +482,10 @@ export const useRemoveNIC = () => {
} }
// Snapshot management hooks // Snapshot management hooks
export const useSnapshots = (connectionId: string, node: string, vmid: number) => { export const useSnapshots = (connectionId: string, node: string, vmid: number, vmType: string) => {
return useQuery({ return useQuery({
queryKey: queryKeys.snapshots(connectionId, node, vmid), queryKey: queryKeys.snapshots(connectionId, node, vmid),
queryFn: () => api.getSnapshots(connectionId, node, vmid), queryFn: () => api.getSnapshots(connectionId, node, vmid, vmType),
enabled: !!connectionId && !!node && vmid > 0, enabled: !!connectionId && !!node && vmid > 0,
}) })
} }
@@ -471,16 +498,19 @@ export const useCreateSnapshot = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
config, config,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
config: CreateSnapshotConfig config: CreateSnapshotConfig
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.createSnapshot(connId, node, vmid, config) return api.createSnapshot(connId, node, vmid, vmType, config)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Snapshot created', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -502,16 +532,19 @@ export const useDeleteSnapshot = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
name, name,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
name: string name: string
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.deleteSnapshot(connId, node, vmid, name) return api.deleteSnapshot(connId, node, vmid, vmType, name)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Snapshot deleted', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -533,16 +566,19 @@ export const useRollbackSnapshot = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
name, name,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
name: string name: string
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.rollbackSnapshot(connId, node, vmid, name) return api.rollbackSnapshot(connId, node, vmid, vmType, name)
}, },
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
addToast('Snapshot restored', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -565,18 +601,21 @@ export const useMigrateVM = () => {
mutationFn: ({ mutationFn: ({
node, node,
vmid, vmid,
vmType,
targetNode, targetNode,
online, online,
}: { }: {
node: string node: string
vmid: number vmid: number
vmType: string
targetNode: string targetNode: string
online: boolean online: boolean
}) => { }) => {
const connId = useConnectionStore.getState().activeConnectionId! const connId = useConnectionStore.getState().activeConnectionId!
return api.migrateVM(connId, node, vmid, targetNode, online) return api.migrateVM(connId, node, vmid, vmType, targetNode, online)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Migration started', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -634,6 +673,7 @@ export const useCreateBackupJob = () => {
return api.createBackupJob(connId, config) return api.createBackupJob(connId, config)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Backup job created', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
@@ -655,6 +695,7 @@ export const useUpdateBackupJob = () => {
return api.updateBackupJob(connId, id, config) return api.updateBackupJob(connId, id, config)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Backup job updated', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
@@ -676,6 +717,7 @@ export const useDeleteBackupJob = () => {
return api.deleteBackupJob(connId, id) return api.deleteBackupJob(connId, id)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Backup job deleted', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
@@ -697,6 +739,7 @@ export const useRunBackup = () => {
return api.runBackup(connId, config) return api.runBackup(connId, config)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Backup started', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
@@ -718,6 +761,7 @@ export const useRestoreBackup = () => {
return api.restoreBackup(connId, volid, config) return api.restoreBackup(connId, volid, config)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Restore started', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
@@ -740,6 +784,7 @@ export const useDeleteBackup = () => {
return api.deleteBackup(connId, volid) return api.deleteBackup(connId, volid)
}, },
onSuccess: () => { onSuccess: () => {
addToast('Backup deleted', 'success')
const connId = useConnectionStore.getState().activeConnectionId const connId = useConnectionStore.getState().activeConnectionId
if (connId) { if (connId) {
queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) }) queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) })
+149 -39
View File
@@ -1,56 +1,166 @@
@import 'tailwindcss'; @import 'tailwindcss';
@theme { @theme {
--color-background: oklch(1 0 0); /* ------------------------------------------------------------------
--color-foreground: oklch(0.145 0 0); * Typography — offline-safe variable fonts (bundled via fontsource)
--color-card: oklch(1 0 0); * ------------------------------------------------------------------ */
--color-card-foreground: oklch(0.145 0 0); --font-sans:
--color-popover: oklch(1 0 0); 'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
--color-popover-foreground: oklch(0.145 0 0); 'Segoe UI', sans-serif;
--color-primary: oklch(0.205 0 0); --font-mono:
--color-primary-foreground: oklch(0.985 0 0); 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
--color-secondary: oklch(0.97 0 0); Menlo, monospace;
--color-secondary-foreground: oklch(0.205 0 0);
--color-muted: oklch(0.97 0 0); /* ------------------------------------------------------------------
--color-muted-foreground: oklch(0.556 0 0); * Radius scale — tighter on inner elements, softer on containers
--color-accent: oklch(0.97 0 0); * ------------------------------------------------------------------ */
--color-accent-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--color-destructive-foreground: oklch(0.577 0.245 27.325);
--color-border: oklch(0.922 0 0);
--color-input: oklch(0.922 0 0);
--color-ring: oklch(0.708 0 0);
--radius: 0.625rem; --radius: 0.625rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.625rem;
--radius-xl: 0.75rem;
/* ------------------------------------------------------------------
* Light theme (default) — warm paper neutrals, cool ink, Proxmox accent
* ------------------------------------------------------------------ */
--color-background: oklch(0.972 0.004 85);
--color-foreground: oklch(0.24 0.012 250);
--color-card: oklch(0.998 0.002 85);
--color-card-foreground: oklch(0.24 0.012 250);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.24 0.012 250);
--color-primary: oklch(0.62 0.15 55);
--color-primary-foreground: oklch(0.985 0.008 60);
--color-secondary: oklch(0.95 0.005 85);
--color-secondary-foreground: oklch(0.3 0.012 250);
--color-muted: oklch(0.95 0.005 85);
--color-muted-foreground: oklch(0.51 0.014 250);
--color-accent: oklch(0.935 0.008 85);
--color-accent-foreground: oklch(0.28 0.012 250);
--color-destructive: oklch(0.56 0.19 25);
--color-destructive-foreground: oklch(0.98 0.008 25);
--color-border: oklch(0.905 0.006 85);
--color-input: oklch(0.885 0.007 85);
--color-ring: oklch(0.62 0.15 55);
/* Semantic status colors — data semantics, not UI accents */
--color-success: oklch(0.55 0.13 155);
--color-warning: oklch(0.62 0.13 80);
--color-info: oklch(0.55 0.11 245);
/* Tinted card shadow — carries the warm hue of the surface */
--shadow-card:
0 1px 2px 0 oklch(0.3 0.02 60 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 60 / 0.05);
} }
.dark { .dark {
--color-background: oklch(0.145 0 0); /* ------------------------------------------------------------------
--color-foreground: oklch(0.985 0 0); * Dark theme — blue-black ink surfaces, cool neutrals, muted orange
--color-card: oklch(0.145 0 0); * ------------------------------------------------------------------ */
--color-card-foreground: oklch(0.985 0 0); --color-background: oklch(0.16 0.011 250);
--color-popover: oklch(0.145 0 0); --color-foreground: oklch(0.965 0.004 250);
--color-popover-foreground: oklch(0.985 0 0); --color-card: oklch(0.19 0.012 250);
--color-primary: oklch(0.985 0 0); --color-card-foreground: oklch(0.965 0.004 250);
--color-primary-foreground: oklch(0.205 0 0); --color-popover: oklch(0.215 0.013 250);
--color-secondary: oklch(0.269 0 0); --color-popover-foreground: oklch(0.965 0.004 250);
--color-secondary-foreground: oklch(0.985 0 0); --color-primary: oklch(0.72 0.15 60);
--color-muted: oklch(0.269 0 0); --color-primary-foreground: oklch(0.18 0.02 50);
--color-muted-foreground: oklch(0.708 0 0); --color-secondary: oklch(0.235 0.011 250);
--color-accent: oklch(0.269 0 0); --color-secondary-foreground: oklch(0.94 0.004 250);
--color-accent-foreground: oklch(0.985 0 0); --color-muted: oklch(0.235 0.011 250);
--color-destructive: oklch(0.577 0.245 27.325); --color-muted-foreground: oklch(0.655 0.012 250);
--color-destructive-foreground: oklch(0.577 0.245 27.325); --color-accent: oklch(0.275 0.012 250);
--color-border: oklch(0.269 0 0); --color-accent-foreground: oklch(0.965 0.004 250);
--color-input: oklch(0.269 0 0); --color-destructive: oklch(0.645 0.18 25);
--color-ring: oklch(0.439 0 0); --color-destructive-foreground: oklch(0.97 0.015 25);
--color-border: oklch(0.255 0.012 250);
--color-input: oklch(0.3 0.013 250);
--color-ring: oklch(0.72 0.15 60);
--color-success: oklch(0.7 0.14 155);
--color-warning: oklch(0.76 0.13 80);
--color-info: oklch(0.68 0.11 245);
--shadow-card:
0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body { body {
@apply bg-background text-foreground; @apply bg-background font-sans text-foreground antialiased;
}
/* Keep native form controls and webview widgets on theme */
::selection {
@apply bg-primary/20 text-foreground;
}
/* Monospace and code contexts always use tabular numerals */
code,
kbd,
samp,
pre {
font-variant-numeric: tabular-nums;
}
/* Consistent focus ring everywhere, including custom elements */
:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Native scrollbars tuned to the surface language */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted-foreground);
background-clip: padding-box;
}
/* Very subtle fixed grain overlay to break digital flatness */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.02;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
html.dark body::after {
opacity: 0.04;
} }
} }
+96 -28
View File
@@ -1,7 +1,14 @@
// Tauri IPC commands interface // Tauri IPC commands interface
// These functions call into the Rust backend via Tauri's invoke mechanism // These functions call into the Rust backend via Tauri's invoke mechanism
import type { ConnectionConfig, CertificateInfo, LoginResult } from '@/types/connection' import type {
ConnectionConfig,
CertificateInfo,
LoginResult,
LoadConnectionsResult,
ConnectResult,
ConnectionStatusInfo,
} from '@/types/connection'
import type { import type {
ProxmoxNode, ProxmoxNode,
ProxmoxVM, ProxmoxVM,
@@ -36,6 +43,14 @@ const mockResponse = <T>(data: T): Promise<T> => {
} }
// Connection management // Connection management
export const loadConnections = async (): Promise<LoadConnectionsResult> => {
if (!isTauri()) {
return mockResponse({ activeConnectionId: null, connections: [] })
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('load_connections')
}
export const addConnection = async (config: ConnectionConfig): Promise<void> => { export const addConnection = async (config: ConnectionConfig): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
@@ -48,18 +63,48 @@ export const removeConnection = async (id: string): Promise<void> => {
return invoke('remove_connection', { id }) return invoke('remove_connection', { id })
} }
export const connectToServer = async (id: string): Promise<void> => { export const updateConnection = async (config: ConnectionConfig): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('update_connection', { config })
}
export const connectToServer = async (id: string): Promise<ConnectResult> => {
if (!isTauri()) {
return mockResponse({ connectionId: id, mergedInto: null, status: 'connected' })
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('connect_to_server', { id }) return invoke('connect_to_server', { id })
} }
export const getConnectionStatus = async (
connectionId: string,
): Promise<ConnectionStatusInfo> => {
if (!isTauri()) {
return mockResponse({
connectionId,
status: 'connected',
primaryUrl: '',
currentEndpointUrl: '',
nodes: [],
})
}
const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_connection_status', { connectionId })
}
export const disconnectFromServer = async (id: string): Promise<void> => { export const disconnectFromServer = async (id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('disconnect_from_server', { id }) return invoke('disconnect_from_server', { id })
} }
export const setActiveConnection = async (id: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core')
return invoke('set_active_connection', { id })
}
// Authentication // Authentication
export const loginWithPassword = async ( export const loginWithPassword = async (
url: string, url: string,
@@ -222,40 +267,40 @@ export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClu
} }
// VM lifecycle // VM lifecycle
export const startVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const startVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('start_vm', { connectionId, node, vmid }) return invoke('start_vm', { connectionId, node, vmid, vmType })
} }
export const stopVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const stopVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('stop_vm', { connectionId, node, vmid }) return invoke('stop_vm', { connectionId, node, vmid, vmType })
} }
export const shutdownVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const shutdownVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('shutdown_vm', { connectionId, node, vmid }) return invoke('shutdown_vm', { connectionId, node, vmid, vmType })
} }
export const rebootVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const rebootVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('reboot_vm', { connectionId, node, vmid }) return invoke('reboot_vm', { connectionId, node, vmid, vmType })
} }
export const suspendVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const suspendVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('suspend_vm', { connectionId, node, vmid }) return invoke('suspend_vm', { connectionId, node, vmid, vmType })
} }
export const resumeVM = async (connectionId: string, node: string, vmid: number): Promise<void> => { export const resumeVM = async (connectionId: string, node: string, vmid: number, vmType: string): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('resume_vm', { connectionId, node, vmid }) return invoke('resume_vm', { connectionId, node, vmid, vmType })
} }
// Disk management // Disk management
@@ -263,6 +308,7 @@ export const getDisks = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
): Promise<ProxmoxDisk[]> => { ): Promise<ProxmoxDisk[]> => {
if (!isTauri()) { if (!isTauri()) {
return mockResponse([ return mockResponse([
@@ -271,53 +317,57 @@ export const getDisks = async (
]) ])
} }
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_disks', { connectionId, node, vmid }) return invoke('get_disks', { connectionId, node, vmid, vmType })
} }
export const addDisk = async ( export const addDisk = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
config: AddDiskConfig, config: AddDiskConfig,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('add_disk', { connectionId, node, vmid, config }) return invoke('add_disk', { connectionId, node, vmid, vmType, config })
} }
export const resizeDisk = async ( export const resizeDisk = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
disk: string, disk: string,
size: number, size: number,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('resize_disk', { connectionId, node, vmid, disk, size }) return invoke('resize_disk', { connectionId, node, vmid, vmType, disk, size })
} }
export const removeDisk = async ( export const removeDisk = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
disk: string, disk: string,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('remove_disk', { connectionId, node, vmid, disk }) return invoke('remove_disk', { connectionId, node, vmid, vmType, disk })
} }
export const moveDisk = async ( export const moveDisk = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
disk: string, disk: string,
storage: string, storage: string,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('move_disk', { connectionId, node, vmid, disk, storage }) return invoke('move_disk', { connectionId, node, vmid, vmType, disk, storage })
} }
// Network management // Network management
@@ -325,6 +375,7 @@ export const getNetworkInterfaces = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
): Promise<ProxmoxNetwork[]> => { ): Promise<ProxmoxNetwork[]> => {
if (!isTauri()) { if (!isTauri()) {
return mockResponse([ return mockResponse([
@@ -332,41 +383,44 @@ export const getNetworkInterfaces = async (
]) ])
} }
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_network_interfaces', { connectionId, node, vmid }) return invoke('get_network_interfaces', { connectionId, node, vmid, vmType })
} }
export const addNIC = async ( export const addNIC = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
config: AddNICConfig, config: AddNICConfig,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('add_nic', { connectionId, node, vmid, config }) return invoke('add_nic', { connectionId, node, vmid, vmType, config })
} }
export const editNIC = async ( export const editNIC = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
nic: string, nic: string,
config: EditNICConfig, config: EditNICConfig,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('edit_nic', { connectionId, node, vmid, nic, config }) return invoke('edit_nic', { connectionId, node, vmid, vmType, nic, config })
} }
export const removeNIC = async ( export const removeNIC = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
nic: string, nic: string,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('remove_nic', { connectionId, node, vmid, nic }) return invoke('remove_nic', { connectionId, node, vmid, vmType, nic })
} }
// Snapshot management // Snapshot management
@@ -374,43 +428,47 @@ export const getSnapshots = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
): Promise<ProxmoxSnapshot[]> => { ): Promise<ProxmoxSnapshot[]> => {
if (!isTauri()) return mockResponse([]) if (!isTauri()) return mockResponse([])
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('get_snapshots', { connectionId, node, vmid }) return invoke('get_snapshots', { connectionId, node, vmid, vmType })
} }
export const createSnapshot = async ( export const createSnapshot = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
config: CreateSnapshotConfig, config: CreateSnapshotConfig,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('create_snapshot', { connectionId, node, vmid, config }) return invoke('create_snapshot', { connectionId, node, vmid, vmType, config })
} }
export const deleteSnapshot = async ( export const deleteSnapshot = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
name: string, name: string,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('delete_snapshot', { connectionId, node, vmid, name }) return invoke('delete_snapshot', { connectionId, node, vmid, vmType, name })
} }
export const rollbackSnapshot = async ( export const rollbackSnapshot = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
name: string, name: string,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('rollback_snapshot', { connectionId, node, vmid, name }) return invoke('rollback_snapshot', { connectionId, node, vmid, vmType, name })
} }
// VM migration // VM migration
@@ -418,12 +476,13 @@ export const migrateVM = async (
connectionId: string, connectionId: string,
node: string, node: string,
vmid: number, vmid: number,
vmType: string,
targetNode: string, targetNode: string,
online: boolean, online: boolean,
): Promise<void> => { ): Promise<void> => {
if (!isTauri()) return mockResponse(undefined) if (!isTauri()) return mockResponse(undefined)
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('migrate_vm', { connectionId, node, vmid, targetNode, online }) return invoke('migrate_vm', { connectionId, node, vmid, vmType, targetNode, online })
} }
// Console proxy // Console proxy
@@ -565,3 +624,12 @@ export const deleteBackup = async (connectionId: string, volid: string): Promise
const { invoke } = await import('@tauri-apps/api/core') const { invoke } = await import('@tauri-apps/api/core')
return invoke('delete_backup', { connectionId, volid }) return invoke('delete_backup', { connectionId, volid })
} }
// Tray menu
export const updateTrayMenu = async (
connections: { id: string; name: string; status: string }[],
): Promise<void> => {
if (!isTauri()) return
const { invoke } = await import('@tauri-apps/api/core')
return invoke('update_tray_menu', { connections })
}
+2
View File
@@ -1,5 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@fontsource-variable/geist'
import '@fontsource-variable/jetbrains-mono'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
+13 -1
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ConnectionConfig, ConnectionStatus } from '@/types/connection' import type { ConnectionConfig, ConnectionStatus, DiscoveredNode } from '@/types/connection'
export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated' export type AuthStatus = 'authenticated' | 'expired' | 'unauthenticated'
@@ -14,8 +14,10 @@ interface ConnectionState {
addConnection: (config: ConnectionConfig) => void addConnection: (config: ConnectionConfig) => void
removeConnection: (id: string) => void removeConnection: (id: string) => void
updateConnection: (id: string, updates: Partial<ConnectionConfig>) => void updateConnection: (id: string, updates: Partial<ConnectionConfig>) => void
hydrate: (connections: ConnectionConfig[], activeConnectionId: string | null) => void
setActiveConnection: (id: string | null) => void setActiveConnection: (id: string | null) => void
setConnectionStatus: (id: string, status: ConnectionStatus) => void setConnectionStatus: (id: string, status: ConnectionStatus) => void
setConnectionNodes: (id: string, nodes: DiscoveredNode[]) => void
setLoading: (loading: boolean) => void setLoading: (loading: boolean) => void
setError: (error: string | null) => void setError: (error: string | null) => void
setAuthStatus: (status: AuthStatus) => void setAuthStatus: (status: AuthStatus) => void
@@ -47,6 +49,9 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
), ),
})), })),
hydrate: (connections, activeConnectionId) =>
set({ connections, activeConnectionId }),
setActiveConnection: (id) => setActiveConnection: (id) =>
set({ activeConnectionId: id }), set({ activeConnectionId: id }),
@@ -57,6 +62,13 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
), ),
})), })),
setConnectionNodes: (id, nodes) =>
set((state) => ({
connections: state.connections.map((c) =>
c.id === id ? { ...c, nodes } : c
),
})),
setLoading: (loading) => set({ isLoading: loading }), setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
+36
View File
@@ -9,11 +9,47 @@ export interface ConnectionConfig {
fallbacks: EndpointConfig[] fallbacks: EndpointConfig[]
certFingerprint?: string certFingerprint?: string
trusted: boolean trusted: boolean
acceptUntrusted?: boolean
status: ConnectionStatus status: ConnectionStatus
clusterName?: string clusterName?: string
clusterId?: string
isCluster: boolean isCluster: boolean
authMode: AuthMode authMode: AuthMode
username?: string username?: string
/** Nodes discovered in the cluster this connection is anchored on. */
nodes?: DiscoveredNode[]
/** The endpoint currently serving this connection (after failover). */
currentEndpointUrl?: string
}
/** A node discovered in the cluster connected through a connection. */
export interface DiscoveredNode {
name: string
url: string
status: 'online' | 'offline'
isPrimary: boolean
local: boolean
}
/** The outcome of a connect attempt, returned by `connect_to_server`. */
export interface ConnectResult {
connectionId: string
mergedInto: string | null
status: 'connected' | 'failover' | 'failed'
}
/** A snapshot of a connection's runtime state, returned by `get_connection_status`. */
export interface ConnectionStatusInfo {
connectionId: string
status: 'connected' | 'failover' | 'failed' | 'disconnected'
primaryUrl: string
currentEndpointUrl: string
nodes: DiscoveredNode[]
}
export interface LoadConnectionsResult {
activeConnectionId: string | null
connections: ConnectionConfig[]
} }
export interface EndpointConfig { export interface EndpointConfig {