feat: add cluster discovery, failover, VM management, and console support
This commit is contained in:
@@ -1,53 +1,128 @@
|
||||
# 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
|
||||
|
||||
### Phase 1: Foundation (Current)
|
||||
- ✅ 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
|
||||
### Connections and authentication
|
||||
|
||||
### Planned Features
|
||||
- VM & Container lifecycle management (start, stop, reboot, shutdown)
|
||||
- Console access (noVNC for VMs, xterm.js for containers)
|
||||
- Disk management (add, resize, remove, move)
|
||||
- Network interface management
|
||||
- Backup job management and restore
|
||||
- Snapshot management
|
||||
- System tray integration
|
||||
- Command palette (Cmd/Ctrl+K)
|
||||
- Automatic failover with primary/fallback endpoints
|
||||
- Manage multiple simultaneous connections to servers and clusters.
|
||||
- Authenticate with an API token or with a username and password. Password mode uses ticket-based auth (PVEAuthCookie plus CSRF token).
|
||||
- Credentials live in the OS keyring. API tokens never touch disk.
|
||||
- Connections persist to `{app-config-dir}/proxmoxdesktop/connections.json`. The last active connection reconnects on launch.
|
||||
|
||||
### Cluster support
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
### 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)
|
||||
- **Tauri 2** - Desktop app framework
|
||||
- **Rust** - Backend logic
|
||||
- **reqwest** - HTTP client with TLS support
|
||||
- **keyring** - OS keyring integration
|
||||
- **tokio** - Async runtime
|
||||
- React 19, TypeScript
|
||||
- Vite, Tailwind CSS v4
|
||||
- Radix UI-based components (shadcn-style)
|
||||
- TanStack Query (server state), Zustand (client state)
|
||||
- lucide-react icons, Recharts gauges, noVNC, xterm.js
|
||||
|
||||
### 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
|
||||
|
||||
### System Dependencies
|
||||
### System dependencies
|
||||
|
||||
#### Linux (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
@@ -64,35 +139,30 @@ sudo apt install -y \
|
||||
```
|
||||
|
||||
#### macOS
|
||||
```bash
|
||||
# Install Xcode Command Line Tools
|
||||
xcode-select --install
|
||||
|
||||
# Install Homebrew dependencies
|
||||
```bash
|
||||
xcode-select --install
|
||||
brew install rust
|
||||
```
|
||||
|
||||
#### 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
|
||||
winget install Rustlang.Rustup
|
||||
```
|
||||
- Install Visual Studio Build Tools and select "Desktop development with C++".
|
||||
- Install Rust via rustup: `winget install Rustlang.Rustup`
|
||||
|
||||
### Node.js
|
||||
|
||||
Node.js 20 or newer (nvm recommended):
|
||||
|
||||
```bash
|
||||
# Install Node.js 20+ via nvm (recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
```
|
||||
|
||||
### Rust
|
||||
|
||||
```bash
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source $HOME/.cargo/env
|
||||
```
|
||||
@@ -100,166 +170,163 @@ source $HOME/.cargo/env
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd ProxmoxDesktop
|
||||
|
||||
# Install Node.js dependencies
|
||||
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
|
||||
|
||||
### Run in Development Mode
|
||||
### Run in the browser (mock data)
|
||||
|
||||
```bash
|
||||
# Start the Vite dev server
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
# Build the application
|
||||
npm run tauri build
|
||||
npm run build # type-check and bundle the frontend
|
||||
npm run tauri build # production bundle in src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
The built application will be in `src-tauri/target/release/bundle/`.
|
||||
|
||||
### Run Tests
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
# Run linter
|
||||
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
|
||||
|
||||
```
|
||||
ProxmoxDesktop/
|
||||
├── src/ # React frontend
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── layout/ # Layout components (Sidebar, Dashboard)
|
||||
│ │ └── connections/ # Connection management UI
|
||||
│ ├── hooks/ # React Query hooks
|
||||
│ ├── stores/ # Zustand stores
|
||||
│ ├── lib/ # Utilities and Tauri IPC
|
||||
│ │ ├── ui/ # base components (buttons, dialogs, toasts)
|
||||
│ │ ├── layout/ # sidebar, dashboard shell
|
||||
│ │ ├── connections/ # connection manager and dialog
|
||||
│ │ ├── dashboard/ # node health grid, gauges, activity feed, quick actions
|
||||
│ │ ├── nodes/ # node list and detail
|
||||
│ │ ├── 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
|
||||
│ ├── App.tsx # Main app component
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Global styles
|
||||
│ ├── App.tsx
|
||||
│ ├── main.tsx
|
||||
│ └── index.css
|
||||
├── src-tauri/ # Tauri/Rust backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point
|
||||
│ │ ├── lib.rs # Tauri commands and app setup
|
||||
│ │ ├── connection.rs # Connection manager
|
||||
│ │ ├── main.rs # binary entry point
|
||||
│ │ ├── lib.rs # Tauri commands, app setup, tray menu
|
||||
│ │ ├── connection.rs # ConnectionManager, api_request core
|
||||
│ │ ├── proxmox.rs # Proxmox API types
|
||||
│ │ └── error.rs # Error handling
|
||||
│ ├── Cargo.toml # Rust dependencies
|
||||
│ └── tauri.conf.json # Tauri configuration
|
||||
├── package.json # Node.js dependencies
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
│ │ ├── tls.rs # certificate capture and TOFU pinning
|
||||
│ │ ├── websocket.rs # WebSocket event relay
|
||||
│ │ └── error.rs # error mapping
|
||||
│ ├── tests/ # integration tests (httpmock, rcgen, tempfile)
|
||||
│ │ ├── api_client.rs
|
||||
│ │ ├── 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
|
||||
|
||||
### Linux: Missing System Dependencies
|
||||
### Linux: missing system dependencies
|
||||
|
||||
If you see errors about missing libraries:
|
||||
```bash
|
||||
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:
|
||||
- Visual Studio Build Tools with "Desktop development with C++"
|
||||
- WebView2 (usually pre-installed on Windows 10/11)
|
||||
Make sure you have Visual Studio Build Tools with "Desktop development with C++" and WebView2 (preinstalled on Windows 10 and 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
|
||||
|
||||
Contributions are welcome! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run `npm run lint` and fix any issues
|
||||
5. Submit a pull request
|
||||
Contributions are welcome. Please:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch.
|
||||
3. Make your changes.
|
||||
4. Run `npm run lint` and `cargo test`, and fix any issues.
|
||||
5. Submit a pull request.
|
||||
|
||||
## License
|
||||
|
||||
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
|
||||
|
||||
Generated
+20
@@ -8,6 +8,8 @@
|
||||
"name": "proxmoxdesktop",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
@@ -561,6 +563,24 @@
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
+3
-1
@@ -11,6 +11,8 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
@@ -37,13 +39,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/novnc": "^0.0.27",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
Generated
+288
@@ -47,6 +47,48 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -849,6 +891,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1005,6 +1067,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
@@ -1317,6 +1385,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1329,6 +1416,30 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1396,6 +1507,46 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "hyper"
|
||||
version = "1.11.0"
|
||||
@@ -1406,9 +1557,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1865,6 +2018,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -2286,6 +2445,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2309,6 +2474,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -2526,7 +2710,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"httpmock",
|
||||
"keyring",
|
||||
"percent-encoding",
|
||||
"rcgen",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
@@ -2536,8 +2723,10 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-shell",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -2692,6 +2881,19 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2862,6 +3064,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
@@ -3118,6 +3333,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
@@ -3298,6 +3523,12 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3414,6 +3645,12 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringmetrics"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -3502,6 +3739,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
@@ -3809,6 +4055,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
@@ -3997,6 +4256,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -4172,10 +4432,23 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"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]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4298,6 +4571,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5174,6 +5453,15 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
|
||||
@@ -20,12 +20,19 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "linux-
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2"
|
||||
tokio-rustls = "0.26"
|
||||
x509-cert = "0.2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
percent-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
httpmock = "0.8"
|
||||
rcgen = "0.13"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
@@ -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
+1846
-190
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ pub enum Error {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Cannot connect to server: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("Certificate error: {0}")]
|
||||
CertificateError(String),
|
||||
|
||||
|
||||
+235
-54
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
@@ -6,12 +7,19 @@ use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod connection;
|
||||
mod proxmox;
|
||||
mod error;
|
||||
mod proxmox;
|
||||
pub mod tls;
|
||||
mod websocket;
|
||||
|
||||
use connection::ConnectionManager;
|
||||
use error::Error;
|
||||
pub use connection::{
|
||||
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;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -23,13 +31,36 @@ pub struct ConnectionConfig {
|
||||
pub name: String,
|
||||
pub primary: EndpointConfig,
|
||||
pub fallbacks: Vec<EndpointConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cert_fingerprint: Option<String>,
|
||||
pub trusted: bool,
|
||||
#[serde(default)]
|
||||
pub accept_untrusted: bool,
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cluster_name: Option<String>,
|
||||
pub is_cluster: bool,
|
||||
pub auth_mode: 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)]
|
||||
@@ -37,6 +68,7 @@ pub struct ConnectionConfig {
|
||||
pub struct EndpointConfig {
|
||||
pub url: String,
|
||||
pub node: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
@@ -48,7 +80,38 @@ pub struct LoginResult {
|
||||
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")]
|
||||
pub struct CertificateInfo {
|
||||
pub fingerprint: String,
|
||||
@@ -64,38 +127,91 @@ struct AppState {
|
||||
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]
|
||||
async fn add_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: ConnectionConfig,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
async fn remove_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
async fn connect_to_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<ConnectResult> {
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
manager.connect(&id).await
|
||||
let path = connections_file(&app)?;
|
||||
manager.connect(&id, &path).await
|
||||
}
|
||||
|
||||
#[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>,
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> 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;
|
||||
manager.disconnect(&id).await
|
||||
}
|
||||
@@ -114,9 +230,11 @@ async fn trust_certificate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
fingerprint: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.trust_certificate(&id, &fingerprint).await
|
||||
let mut manager = state.connection_manager.write().await;
|
||||
let path = connections_file(&app)?;
|
||||
manager.trust_certificate(&id, &fingerprint, &path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -164,7 +282,9 @@ async fn get_storage_detail(
|
||||
storage: String,
|
||||
) -> Result<proxmox::StorageDetail> {
|
||||
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]
|
||||
@@ -191,9 +311,12 @@ async fn start_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -202,9 +325,10 @@ async fn stop_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -213,9 +337,12 @@ async fn shutdown_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -224,9 +351,12 @@ async fn reboot_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -235,9 +365,12 @@ async fn suspend_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -246,9 +379,12 @@ async fn resume_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -257,9 +393,12 @@ async fn get_disks(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<Vec<proxmox::Disk>> {
|
||||
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]
|
||||
@@ -268,10 +407,13 @@ async fn add_disk(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
config: proxmox::AddDiskConfig,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -280,11 +422,14 @@ async fn resize_disk(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
disk: String,
|
||||
size: u64,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -293,10 +438,13 @@ async fn remove_disk(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
disk: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -305,11 +453,14 @@ async fn move_disk(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
disk: String,
|
||||
storage: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -318,9 +469,12 @@ async fn get_network_interfaces(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<Vec<proxmox::NetworkInterface>> {
|
||||
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]
|
||||
@@ -329,10 +483,13 @@ async fn add_nic(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
config: proxmox::AddNICConfig,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -341,11 +498,14 @@ async fn edit_nic(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
nic: String,
|
||||
config: proxmox::EditNICConfig,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -354,10 +514,13 @@ async fn remove_nic(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
nic: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -366,9 +529,12 @@ async fn get_snapshots(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
) -> Result<Vec<proxmox::Snapshot>> {
|
||||
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]
|
||||
@@ -377,10 +543,13 @@ async fn create_snapshot(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
config: proxmox::CreateSnapshotConfig,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -389,10 +558,13 @@ async fn delete_snapshot(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -401,10 +573,13 @@ async fn rollback_snapshot(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
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]
|
||||
@@ -413,11 +588,14 @@ async fn migrate_vm(
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
target_node: String,
|
||||
online: bool,
|
||||
) -> Result<()> {
|
||||
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
|
||||
@@ -429,7 +607,9 @@ async fn login_with_password(
|
||||
password: String,
|
||||
) -> Result<LoginResult> {
|
||||
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]
|
||||
@@ -443,10 +623,7 @@ async fn login_with_token(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn logout(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<()> {
|
||||
async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.logout(&connection_id).await
|
||||
}
|
||||
@@ -461,7 +638,7 @@ async fn get_stored_credentials(
|
||||
}
|
||||
|
||||
// Console proxy types
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VNCProxyResponse {
|
||||
pub ticket: String,
|
||||
@@ -469,7 +646,7 @@ pub struct VNCProxyResponse {
|
||||
pub cert: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TermProxyResponse {
|
||||
pub ticket: String,
|
||||
@@ -554,7 +731,9 @@ async fn get_backups(
|
||||
storage: Option<String>,
|
||||
) -> Result<Vec<proxmox::Backup>> {
|
||||
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]
|
||||
@@ -689,10 +868,14 @@ pub fn run() {
|
||||
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
load_connections,
|
||||
add_connection,
|
||||
remove_connection,
|
||||
update_connection,
|
||||
set_active_connection,
|
||||
connect_to_server,
|
||||
disconnect_from_server,
|
||||
get_connection_status,
|
||||
login_with_password,
|
||||
login_with_token,
|
||||
logout,
|
||||
@@ -759,23 +942,21 @@ pub fn run() {
|
||||
.tooltip("ProxmoxDesktop")
|
||||
.icon(app.default_window_icon().cloned().expect("no default icon"))
|
||||
.menu(&menu)
|
||||
.on_menu_event(move |app, event| {
|
||||
match event.id.as_ref() {
|
||||
"show_hide" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||
"show_hide" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ pub struct Disk {
|
||||
pub size: u64,
|
||||
pub storage: String,
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub usage: Option<String>,
|
||||
}
|
||||
|
||||
@@ -21,7 +22,9 @@ pub struct AddDiskConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Node {
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
pub cpu: f64,
|
||||
pub maxcpu: u32,
|
||||
@@ -38,25 +41,44 @@ pub struct Node {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VM {
|
||||
#[serde(default)]
|
||||
pub vmid: u32,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
pub cpus: u32,
|
||||
#[serde(default)]
|
||||
pub mem: u64,
|
||||
#[serde(default)]
|
||||
pub maxmem: u64,
|
||||
#[serde(default)]
|
||||
pub disk: u64,
|
||||
#[serde(default)]
|
||||
pub maxdisk: u64,
|
||||
#[serde(default)]
|
||||
pub uptime: u64,
|
||||
#[serde(default)]
|
||||
pub netin: u64,
|
||||
#[serde(default)]
|
||||
pub netout: u64,
|
||||
#[serde(default)]
|
||||
pub diskread: u64,
|
||||
#[serde(default)]
|
||||
pub diskwrite: u64,
|
||||
#[serde(default)]
|
||||
pub pid: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub template: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub lock: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<String>,
|
||||
}
|
||||
|
||||
@@ -66,12 +88,19 @@ pub struct Storage {
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub active: u32,
|
||||
#[serde(default)]
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
pub shared: u32,
|
||||
#[serde(default)]
|
||||
pub used: u64,
|
||||
#[serde(default)]
|
||||
pub total: u64,
|
||||
#[serde(default)]
|
||||
pub avail: u64,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
}
|
||||
|
||||
@@ -79,15 +108,19 @@ pub struct Storage {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Task {
|
||||
pub upid: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
pub pid: u32,
|
||||
pub pstart: u64,
|
||||
pub starttime: u64,
|
||||
#[serde(default)]
|
||||
pub endtime: Option<u64>,
|
||||
pub r#type: String,
|
||||
pub id: String,
|
||||
pub user: String,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub exitstatus: Option<String>,
|
||||
}
|
||||
|
||||
@@ -114,9 +147,11 @@ pub struct ClusterNode {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub snaptime: u64,
|
||||
pub vmstate: u32,
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
@@ -140,9 +175,13 @@ pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub macaddr: String,
|
||||
#[serde(default)]
|
||||
pub bridge: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tag: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub firewall: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub link_down: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -189,10 +228,15 @@ pub struct BackupJob {
|
||||
pub schedule: String,
|
||||
pub all: u32,
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
pub node: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vmid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub compress: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub quiet: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -224,8 +268,11 @@ pub struct RestoreConfig {
|
||||
pub struct StorageContent {
|
||||
pub content: String,
|
||||
pub ctime: u64,
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub subtype: Option<String>,
|
||||
pub volid: String,
|
||||
}
|
||||
@@ -236,11 +283,18 @@ pub struct StorageDetail {
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub active: u32,
|
||||
#[serde(default)]
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
pub shared: u32,
|
||||
#[serde(default)]
|
||||
pub used: u64,
|
||||
#[serde(default)]
|
||||
pub total: u64,
|
||||
#[serde(default)]
|
||||
pub avail: u64,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ use std::collections::HashMap;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
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())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
vmid: data
|
||||
.get("vmid")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32,
|
||||
vmid: data.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
|
||||
status: data
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -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":{}}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
@@ -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/tables.rs:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
+157
-18
@@ -4,6 +4,8 @@ import { Dashboard } from '@/components/layout/Dashboard'
|
||||
import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
|
||||
import { VMList } from '@/components/vms/VMList'
|
||||
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 { BackupList } from '@/components/backups/BackupList'
|
||||
import { CommandPalette } from '@/components/command/CommandPalette'
|
||||
@@ -11,11 +13,13 @@ import { StorageOverview } from '@/components/storage/StorageOverview'
|
||||
import { StorageDetail } from '@/components/storage/StorageDetail'
|
||||
import { SettingsPage } from '@/components/settings/SettingsPage'
|
||||
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 { useUIStore } from '@/stores/uiStore'
|
||||
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'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -31,6 +35,9 @@ type View =
|
||||
| { type: 'dashboard' }
|
||||
| { type: 'vms' }
|
||||
| { type: 'vm-detail'; vm: ProxmoxVM }
|
||||
| { type: 'nodes' }
|
||||
| { type: 'node-detail'; nodeName: string }
|
||||
| { type: 'containers' }
|
||||
| { type: 'tasks' }
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
@@ -39,17 +46,115 @@ type View =
|
||||
|
||||
function AppContent() {
|
||||
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 [view, setView] = useState<View>({ type: 'dashboard' })
|
||||
const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen)
|
||||
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(() => {
|
||||
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)
|
||||
}
|
||||
}, [activeConnectionId, connectionDialogOpen])
|
||||
}, [connectionsLoaded, connections.length, connectionDialogOpen])
|
||||
|
||||
// WebSocket integration – connects when a connection is active
|
||||
useWebSocket(activeConnectionId)
|
||||
@@ -77,10 +182,15 @@ function AppContent() {
|
||||
|
||||
if (!activeConnectionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-2xl font-semibold">ProxmoxDesktop</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,6 +225,22 @@ function AppContent() {
|
||||
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':
|
||||
return <TaskList connectionId={activeConnectionId} />
|
||||
case 'backups':
|
||||
@@ -146,16 +272,29 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const activeConnection = connections.find((c) => c.id === activeConnectionId)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
onAddConnection={() => setConnectionDialogOpen(true)}
|
||||
activeView={view.type}
|
||||
onNavigate={(v) => handleNavigate(v as View)}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{renderMainContent()}
|
||||
</main>
|
||||
<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
|
||||
onAddConnection={() => setConnectionDialogOpen(true)}
|
||||
activeView={view.type}
|
||||
onNavigate={(v) => handleNavigate(v as View)}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{renderMainContent()}
|
||||
</main>
|
||||
</div>
|
||||
<ConnectionDialog
|
||||
open={connectionDialogOpen}
|
||||
onOpenChange={setConnectionDialogOpen}
|
||||
|
||||
@@ -35,12 +35,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="text-center space-y-4 max-w-md">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
|
||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<div className="max-w-md space-y-4 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { PageSkeleton, Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
@@ -71,9 +74,10 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
|
||||
if (jobsLoading || backupsLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading backups...</p>
|
||||
</div>
|
||||
<PageSkeleton filter>
|
||||
<Skeleton className="h-56 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,8 +95,8 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-6 w-6" />
|
||||
<h2 className="text-2xl font-semibold">Backups</h2>
|
||||
<Shield className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Backups</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Manage backup jobs and existing backups
|
||||
@@ -118,9 +122,9 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
{/* Backup Jobs Section */}
|
||||
<div className="space-y-4">
|
||||
<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)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus />
|
||||
Create Job
|
||||
</Button>
|
||||
</div>
|
||||
@@ -128,61 +132,61 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{!backupJobs || backupJobs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Shield className="h-8 w-8 mx-auto text-muted-foreground/50" />
|
||||
<p>No backup jobs configured</p>
|
||||
<p className="text-sm mt-1">Create a backup job to get started</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No backup jobs configured"
|
||||
description="Create a backup job to get started"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium text-muted-foreground">Status</th>
|
||||
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backupJobs.map((job) => (
|
||||
<tr
|
||||
key={job.id}
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{job.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{job.schedule}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{job.store}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{job.mode ?? 'snapshot'}
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">ID</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Schedule</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Mode</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backupJobs.map((job) => (
|
||||
<tr
|
||||
key={job.id}
|
||||
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{job.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<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" />
|
||||
{job.schedule}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{job.store}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{job.mode ?? 'snapshot'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{job.enabled === 1 ? (
|
||||
<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" />
|
||||
Enabled
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{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">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Enabled
|
||||
</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">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
) : (
|
||||
<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" />
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -217,39 +221,15 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDeleteJob(job.id)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDeleteJob(job.id)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -264,54 +244,51 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
|
||||
{/* Existing Backups Section */}
|
||||
<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>
|
||||
<CardContent className="p-0">
|
||||
{!filteredBackups || filteredBackups.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<HardDrive className="h-8 w-8 mx-auto text-muted-foreground/50" />
|
||||
<p>No backups found</p>
|
||||
</div>
|
||||
<EmptyState icon={HardDrive} title="No backups found" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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-left font-medium text-muted-foreground">Storage</th>
|
||||
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBackups.map((backup) => (
|
||||
<tr
|
||||
key={backup.volid}
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">
|
||||
{backup['backup-id']}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{backup['backup-type']}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{formatTimestamp(backup['backup-time'])}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{formatBytes(backup.size)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{backup.storage}
|
||||
</div>
|
||||
</td>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Date</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Size</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Storage</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBackups.map((backup) => (
|
||||
<tr
|
||||
key={backup.volid}
|
||||
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">
|
||||
{backup['backup-id']}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{backup['backup-type']}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(backup['backup-time'])}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
|
||||
{formatBytes(backup.size)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{backup.storage}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -323,39 +300,15 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDeleteBackup(backup.volid)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDeleteBackup(backup.volid)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -398,6 +351,35 @@ export function BackupList({ connectionId }: BackupListProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export function CreateBackupJobDialog({
|
||||
id="all"
|
||||
checked={all}
|
||||
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">
|
||||
Backup all VMs and containers
|
||||
@@ -167,7 +167,7 @@ export function CreateBackupJobDialog({
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
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">
|
||||
Enabled
|
||||
|
||||
@@ -149,7 +149,7 @@ export function EditBackupJobDialog({
|
||||
id="all"
|
||||
checked={all}
|
||||
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">
|
||||
Backup all VMs and containers
|
||||
@@ -174,7 +174,7 @@ export function EditBackupJobDialog({
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
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">
|
||||
Enabled
|
||||
|
||||
@@ -299,7 +299,7 @@ export function CommandPalette({
|
||||
icon: Square,
|
||||
category: 'actions',
|
||||
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}`,
|
||||
@@ -308,7 +308,7 @@ export function CommandPalette({
|
||||
icon: Power,
|
||||
category: 'actions',
|
||||
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}`,
|
||||
@@ -317,7 +317,7 @@ export function CommandPalette({
|
||||
icon: RotateCw,
|
||||
category: 'actions',
|
||||
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,
|
||||
category: 'actions',
|
||||
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) => (
|
||||
<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}
|
||||
</div>
|
||||
{group.items.map((item, iIdx) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,20 +11,45 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ShieldAlert, Loader2 } from 'lucide-react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { loginWithPassword, loginWithToken, addConnection } from '@/lib/tauri'
|
||||
import type { ConnectionConfig, AuthMode } from '@/types/connection'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
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 {
|
||||
open: boolean
|
||||
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 removeConnectionFromStore = useConnectionStore((s) => s.removeConnection)
|
||||
const setAuthStatus = useConnectionStore((s) => s.setAuthStatus)
|
||||
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 [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
@@ -33,18 +58,119 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [certificate, setCertificate] = useState<CertificateInfo | null>(null)
|
||||
const [pendingResult, setPendingResult] = useState<LoginResult | null>(null)
|
||||
|
||||
const resetForm = () => {
|
||||
setStep('credentials')
|
||||
setName('')
|
||||
setUrl('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setApiToken('')
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
if (editing) {
|
||||
await handleEditSubmit(e)
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
@@ -68,30 +194,39 @@ export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps)
|
||||
result = await loginWithToken(cleanUrl, apiToken)
|
||||
}
|
||||
|
||||
const connectionId = result.connectionId || crypto.randomUUID()
|
||||
if (!isTauri()) {
|
||||
// Browser mock mode: keep the single-step flow
|
||||
const connectionId = result.connectionId || crypto.randomUUID()
|
||||
|
||||
const config: ConnectionConfig = {
|
||||
id: connectionId,
|
||||
name: name || (authMode === 'password' ? username : 'API Token Connection'),
|
||||
primary: {
|
||||
url: cleanUrl,
|
||||
token: authMode === 'token' ? apiToken : undefined,
|
||||
},
|
||||
fallbacks: [],
|
||||
trusted: false,
|
||||
status: 'connected',
|
||||
isCluster: false,
|
||||
authMode,
|
||||
username: authMode === 'password' ? username : undefined,
|
||||
const config: ConnectionConfig = {
|
||||
id: connectionId,
|
||||
name: name || (authMode === 'password' ? username : 'API Token Connection'),
|
||||
primary: {
|
||||
url: cleanUrl,
|
||||
token: authMode === 'token' ? apiToken : undefined,
|
||||
},
|
||||
fallbacks: [],
|
||||
trusted: false,
|
||||
status: 'connected',
|
||||
isCluster: false,
|
||||
authMode,
|
||||
username: authMode === 'password' ? username : undefined,
|
||||
}
|
||||
|
||||
await addConnection(config)
|
||||
addConnectionToStore(config)
|
||||
setActiveConnection(connectionId)
|
||||
setAuthStatus('authenticated')
|
||||
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
await addConnection(config)
|
||||
addConnectionToStore(config)
|
||||
setActiveConnection(connectionId)
|
||||
setAuthStatus('authenticated')
|
||||
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
// Tauri mode: review the server certificate before trusting it
|
||||
setPendingResult(result)
|
||||
setStep('certificate')
|
||||
await loadCertificate(cleanUrl)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect')
|
||||
} finally {
|
||||
@@ -99,107 +234,301 @@ 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect to Proxmox</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sign in with your Proxmox credentials or API token
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{step === 'credentials' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit Connection' : 'Connect to Proxmox'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
|
||||
<TabsTrigger value="token" className="flex-1">API Token</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs value={authMode} onValueChange={(v) => { setAuthMode(v as AuthMode); setError(null) }}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">Username & Password</TabsTrigger>
|
||||
<TabsTrigger value="token" className="flex-1">API Token</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">Server URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://192.168.1.10:8006"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The URL of your Proxmox server (must use HTTPS)
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">Server URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://192.168.1.10:8006"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
readOnly={!!editing}
|
||||
disabled={!!editing}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{editing
|
||||
? 'Server URL cannot be changed while editing'
|
||||
: 'The URL of your Proxmox server (must use HTTPS)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="password" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="root@pam"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: user@realm (e.g. root@pam)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={editing ? 'Leave blank to keep current password' : 'Your password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={!editing}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="token" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">API Token</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
placeholder={editing ? 'Leave blank to keep current token' : 'user@realm!tokenid=secret'}
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
required={!editing}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: user@realm!tokenid=secret
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Connection Name (optional)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Home Lab"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading
|
||||
? (editing ? 'Saving...' : 'Connecting...')
|
||||
: (editing ? 'Save Changes' : 'Connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</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'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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TabsContent value="password" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="root@pam"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: user@realm (e.g. root@pam)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{step === 'connecting' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connecting</DialogTitle>
|
||||
<DialogDescription>
|
||||
Establishing a connection to {url}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<TabsContent value="token" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">API Token</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
placeholder="user@realm!tokenid=secret"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: user@realm!tokenid=secret
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Connection Name (optional)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Home Lab"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{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={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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'
|
||||
|
||||
interface ActivityFeedProps {
|
||||
@@ -8,17 +9,21 @@ interface ActivityFeedProps {
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function isRunningTask(status?: string): boolean {
|
||||
return !status || status === 'Running'
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status?: string): string {
|
||||
switch (status) {
|
||||
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':
|
||||
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:
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Recent Activity</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">
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
)
|
||||
@@ -82,38 +93,43 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Recent Activity</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">
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentTasks.length > 0 ? (
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
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="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{getTaskTypeLabel(task.type)}
|
||||
</p>
|
||||
<p className="truncate text-[13px] font-medium">{getTaskTypeLabel(task.type)}</p>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{task.node} · {task.user}
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<span className="font-mono tabular-nums">{task.node}</span>
|
||||
<span> · </span>
|
||||
{task.user}
|
||||
</p>
|
||||
</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">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatDuration(task.starttime, task.endtime)}</span>
|
||||
@@ -125,7 +141,7 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
|
||||
</div>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
import { formatBytes, formatUptime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NodeHealthGridProps {
|
||||
nodes: ProxmoxNode[] | undefined
|
||||
@@ -11,7 +12,13 @@ interface NodeHealthGridProps {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -40,20 +47,24 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.status)}`} />
|
||||
<CardTitle className="text-sm font-medium">{node.node}</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={cn('h-2 w-2 rounded-full', getStatusColor(node.status))} />
|
||||
<CardTitle className="font-mono text-sm font-semibold tracking-tight">
|
||||
{node.node}
|
||||
</CardTitle>
|
||||
</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>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Sparklines */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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" />
|
||||
<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 className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -61,9 +72,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
fill="hsl(var(--color-primary))"
|
||||
fillOpacity={0.2}
|
||||
stroke="var(--color-primary)"
|
||||
fill="var(--color-primary)"
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
@@ -73,9 +84,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
</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" />
|
||||
<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 className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -83,9 +94,9 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
fill="hsl(var(--color-primary))"
|
||||
fillOpacity={0.2}
|
||||
stroke="var(--color-primary)"
|
||||
fill="var(--color-primary)"
|
||||
fillOpacity={0.18}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
@@ -97,33 +108,33 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu className="h-3 w-3 shrink-0" />
|
||||
<span>{(cpuPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MemoryStick className="h-3 w-3 shrink-0" />
|
||||
<span>{(memPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3 w-3 shrink-0" />
|
||||
<span>{(diskPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom info */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-1 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<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.5">
|
||||
<Clock className="h-3 w-3 shrink-0" />
|
||||
<span>{formatUptime(node.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Box className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Box className="h-3 w-3 shrink-0" />
|
||||
<span>{vmCount} VMs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3 w-3 shrink-0" />
|
||||
<span>{formatBytes(node.disk)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,13 +157,15 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Node Health</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">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
)
|
||||
@@ -161,9 +174,11 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Node Health</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">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 {
|
||||
onRefresh: () => void
|
||||
@@ -11,59 +11,64 @@ interface QuickActionsProps {
|
||||
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<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>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
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}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<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
|
||||
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')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-xs">Nodes</span>
|
||||
<span className="text-xs font-medium">Nodes</span>
|
||||
</Button>
|
||||
<Button
|
||||
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')}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
<span className="text-xs">VMs</span>
|
||||
<span className="text-xs font-medium">VMs</span>
|
||||
</Button>
|
||||
<Button
|
||||
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')}
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-xs">Storage</span>
|
||||
<span className="text-xs font-medium">Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
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')}
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<span className="text-xs">Tasks</span>
|
||||
<span className="text-xs font-medium">Tasks</span>
|
||||
</Button>
|
||||
<Button
|
||||
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')}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-xs">Backups</span>
|
||||
<span className="text-xs font-medium">Backups</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -20,10 +20,23 @@ function formatCores(cores: number): string {
|
||||
return `${cores.toFixed(1)} cores`
|
||||
}
|
||||
|
||||
function getGaugeColor(percent: number): string {
|
||||
if (percent >= 0.9) return 'stroke-destructive'
|
||||
if (percent >= 0.7) return 'stroke-yellow-500'
|
||||
return 'stroke-primary'
|
||||
function getGaugeStyle(percent: number): { className: string; glow: string } {
|
||||
if (percent >= 0.9) {
|
||||
return {
|
||||
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 {
|
||||
@@ -49,16 +62,19 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
|
||||
: formatBytes
|
||||
|
||||
const formatter = formatValue ?? defaultFormat
|
||||
const gaugeStyle = getGaugeStyle(clampedPercent)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
<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>
|
||||
<CardContent className="flex flex-col items-center gap-3">
|
||||
<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 */}
|
||||
<circle
|
||||
cx="50"
|
||||
@@ -78,19 +94,22 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={getGaugeColor(clampedPercent)}
|
||||
className={gaugeStyle.className}
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
transition: 'stroke-dashoffset 0.6s ease-in-out',
|
||||
filter: gaugeStyle.glow,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<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 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)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ActivityFeed } from '@/components/dashboard/ActivityFeed'
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageSkeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface DashboardProps {
|
||||
connectionId: string
|
||||
@@ -36,27 +37,32 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(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) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
return <PageSkeleton />
|
||||
}
|
||||
|
||||
const hasError = nodesError || vmsError || tasksError
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Failed to Load Dashboard</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
|
||||
</p>
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
<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'}
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
Check the connection to your Proxmox server and try again.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -68,65 +74,78 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Dashboard</h2>
|
||||
<p className="text-muted-foreground">Cluster overview and resource usage</p>
|
||||
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Cluster overview and resource usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{nodes?.filter((n) => n.status === 'online').length ?? 0} / {nodes?.length ?? 0}
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{nodes?.filter((n) => n.status === 'online').length ?? 0}
|
||||
<span className="text-muted-foreground"> / {nodes?.length ?? 0}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Online</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">Online</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">CPU Usage</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
|
||||
</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
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Memory</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
|
||||
<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>
|
||||
<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%'}
|
||||
</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)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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%'}
|
||||
</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)}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
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'
|
||||
|
||||
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 {
|
||||
onAddConnection: () => void
|
||||
activeView?: ViewType
|
||||
onNavigate?: (view: { type: ViewType }) => void
|
||||
onNavigate?: (view: NavigationTarget) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProps) {
|
||||
@@ -20,64 +24,126 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
return 'bg-success shadow-[0_0_6px] shadow-success/60'
|
||||
case 'connecting':
|
||||
case 'failover':
|
||||
return 'bg-yellow-500'
|
||||
return 'bg-warning'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
return 'bg-destructive'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
return 'bg-muted-foreground/60'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r bg-card flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-lg font-semibold">ProxmoxDesktop</h1>
|
||||
<div className="flex w-64 flex-col border-r border-border bg-card">
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
|
||||
<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>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{connections.map((connection) => (
|
||||
<div key={connection.id}>
|
||||
<button
|
||||
onClick={() => setActiveConnection(connection.id)}
|
||||
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
|
||||
? '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="font-medium truncate">{connection.name}</span>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusColor(connection.status))} />
|
||||
<span className="truncate font-medium">{connection.name}</span>
|
||||
</button>
|
||||
{activeConnectionId === connection.id && (
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem icon={Server} label="Nodes" disabled />
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem icon={Box} label="Containers" disabled />
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
</div>
|
||||
<>
|
||||
{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">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={activeView === 'dashboard'}
|
||||
onClick={() => onNavigate?.({ type: 'dashboard' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Server}
|
||||
label="Nodes"
|
||||
active={activeView === 'nodes'}
|
||||
onClick={() => onNavigate?.({ type: 'nodes' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="Containers"
|
||||
active={activeView === 'containers'}
|
||||
onClick={() => onNavigate?.({ type: 'containers' })}
|
||||
/>
|
||||
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
|
||||
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
|
||||
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
|
||||
{connection.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>
|
||||
</ScrollArea>
|
||||
<div className="p-2 border-t space-y-1">
|
||||
<div className="space-y-1 border-t border-border p-2">
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
@@ -115,15 +181,18 @@ function SidebarItem({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
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
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
? 'pointer-events-none cursor-not-allowed opacity-50'
|
||||
: active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
||||
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
||||
: '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>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
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 color =
|
||||
percent > 90 ? 'bg-red-500' :
|
||||
percent > 70 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
percent > 90 ? 'bg-destructive' :
|
||||
percent > 70 ? 'bg-warning' :
|
||||
'bg-success'
|
||||
|
||||
return (
|
||||
<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" />
|
||||
{label}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
{percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(used)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,10 +61,10 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<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} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
Uptime: {formatUptime(node.uptime)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,7 +80,7 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
total={node.maxcpu}
|
||||
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
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -113,15 +114,17 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{vmsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading...
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : nodeVMs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -132,22 +135,22 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
{nodeVMs.map((vm) => (
|
||||
<div
|
||||
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-2">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
{vm.vmid}
|
||||
</span>
|
||||
<span className="font-medium">{vm.name}</span>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<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'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,38 +167,38 @@ export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
<CardTitle>System Information</CardTitle>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<span className="text-muted-foreground">Node Name</span>
|
||||
<p className="font-medium">{node.node}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node Name</span>
|
||||
<p className="mt-1 font-mono font-medium">{node.node}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<p className="font-medium capitalize">{node.status}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</span>
|
||||
<p className="mt-1 font-medium capitalize">{node.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Uptime</span>
|
||||
<p className="font-medium">{formatUptime(node.uptime)}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</span>
|
||||
<p className="mt-1 font-mono font-medium tabular-nums">{formatUptime(node.uptime)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Support Level</span>
|
||||
<p className="font-medium">{node.level || 'None'}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Support Level</span>
|
||||
<p className="mt-1 font-medium">{node.level || 'None'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total CPUs</span>
|
||||
<p className="font-medium">{node.maxcpu}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total CPUs</span>
|
||||
<p className="mt-1 font-mono font-medium tabular-nums">{node.maxcpu}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Memory</span>
|
||||
<p className="font-medium">{formatBytes(node.maxmem)}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Memory</span>
|
||||
<p className="mt-1 font-mono font-medium tabular-nums">{formatBytes(node.maxmem)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Disk</span>
|
||||
<p className="font-medium">{formatBytes(node.maxdisk)}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Disk</span>
|
||||
<p className="mt-1 font-mono font-medium tabular-nums">{formatBytes(node.maxdisk)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">ID</span>
|
||||
<p className="font-medium font-mono text-xs">{node.id}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">ID</span>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,45 +2,49 @@ import { useState } from 'react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConnectionConfig } from '@/types/connection'
|
||||
|
||||
export function ConnectionManager() {
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const removeConnection = useConnectionStore((s) => s.removeConnection)
|
||||
const { addToast } = useToast()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<ConnectionConfig | null>(null)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
return 'bg-success shadow-[0_0_6px] shadow-success/60'
|
||||
case 'connecting':
|
||||
case 'failover':
|
||||
return 'bg-yellow-500'
|
||||
return 'bg-warning'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
return 'bg-destructive'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
return 'bg-muted-foreground/60'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
removeConnection(id)
|
||||
setDeleteConfirmId(null)
|
||||
addToast(`Connection "${name}" removed`, 'success')
|
||||
}
|
||||
|
||||
const deleteTarget = connections.find((c) => c.id === deleteConfirmId) ?? null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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' : ''}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
@@ -54,68 +58,66 @@ export function ConnectionManager() {
|
||||
{connections.map((connection) => (
|
||||
<div
|
||||
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={cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
'h-2 w-2 rounded-full',
|
||||
getStatusColor(connection.status)
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteConfirmId(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditing(connection)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteConfirmId(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,11 +53,11 @@ export function SettingsPage() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="flex items-center justify-between">
|
||||
<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 className="pt-2 border-t">
|
||||
<a
|
||||
|
||||
@@ -23,10 +23,10 @@ export function ThemeSwitcher() {
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
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
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-border hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
|
||||
@@ -9,9 +9,9 @@ interface StorageCardProps {
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
if (percent >= 90) return 'bg-destructive'
|
||||
if (percent >= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
function getStorageIcon(type: string) {
|
||||
@@ -67,7 +67,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -77,7 +77,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
|
||||
</CardTitle>
|
||||
<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>
|
||||
{storage.node && <span>{storage.node}</span>}
|
||||
{storage.node && <span className="font-mono">{storage.node}</span>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -85,22 +85,22 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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.total)} total</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(storage.avail)} available
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function StorageCard({ storage, onClick }: StorageCardProps) {
|
||||
return (
|
||||
<span
|
||||
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" />
|
||||
{getContentTypeLabel(type)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { useStorageDetail, useStorageContent } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxStorageContent } from '@/types/proxmox'
|
||||
@@ -13,9 +15,9 @@ interface StorageDetailProps {
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
if (percent >= 90) return 'bg-destructive'
|
||||
if (percent >= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
function getStorageIcon(type: string) {
|
||||
@@ -89,8 +91,19 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
|
||||
if (detailLoading || contentLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading storage details...</p>
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -114,10 +127,11 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<p className="text-muted-foreground">
|
||||
{detail.type.toUpperCase()} storage on {detail.node}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{detail.type.toUpperCase()}</span> storage on{' '}
|
||||
<span className="font-mono">{detail.node}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,15 +146,15 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="h-4 bg-muted rounded-full overflow-hidden">
|
||||
<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)}%` }}
|
||||
/>
|
||||
</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.total)} total</span>
|
||||
</div>
|
||||
@@ -149,24 +163,24 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Total Size</p>
|
||||
<p className="text-sm font-medium">{formatBytes(detail.total)}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Total Size</p>
|
||||
<p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.total)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Used</p>
|
||||
<p className="text-sm font-medium">{formatBytes(detail.used)}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Used</p>
|
||||
<p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.used)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Available</p>
|
||||
<p className="text-sm font-medium">{formatBytes(detail.avail)}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</p>
|
||||
<p className="font-mono text-sm font-medium tabular-nums">{formatBytes(detail.avail)}</p>
|
||||
</div>
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
@@ -180,29 +194,29 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
<CardTitle className="text-base">Storage Information</CardTitle>
|
||||
</CardHeader>
|
||||
<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">
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
<p className="text-sm font-medium">{detail.storage}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</p>
|
||||
<p className="font-mono text-sm font-medium">{detail.storage}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Type</p>
|
||||
<p className="text-sm font-medium uppercase">{detail.type}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Type</p>
|
||||
<p className="font-mono text-sm font-medium uppercase">{detail.type}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Node</p>
|
||||
<p className="text-sm font-medium">{detail.node}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</p>
|
||||
<p className="font-mono text-sm font-medium">{detail.node}</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +237,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!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">
|
||||
{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">
|
||||
<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)}
|
||||
</span>
|
||||
</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 && (
|
||||
<span className="uppercase bg-muted px-1.5 py-0.5 rounded">
|
||||
{item.format}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useStorage } from '@/hooks/useProxmox'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -27,9 +29,13 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading storage pools...</p>
|
||||
</div>
|
||||
<PageSkeleton filter>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +52,7 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{storageList?.length ?? 0} storage pools across the cluster
|
||||
</p>
|
||||
@@ -65,14 +71,11 @@ export function StorageOverview({ connectionId, onStorageClick }: StorageOvervie
|
||||
|
||||
{/* Storage Grid */}
|
||||
{filteredStorage.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<HardDrive className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p>
|
||||
{storageList?.length === 0
|
||||
? 'No storage pools found'
|
||||
: 'No storage pools match your search'}
|
||||
</p>
|
||||
</div>
|
||||
storageList?.length === 0 ? (
|
||||
<EmptyState icon={HardDrive} title="No storage pools found" />
|
||||
) : (
|
||||
<EmptyState icon={Search} title="No storage pools match your search" />
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredStorage.map((storage) => (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useMemo, Fragment } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
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 type { ProxmoxTask } from '@/types/proxmox'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TaskListProps {
|
||||
connectionId: string
|
||||
@@ -44,19 +47,19 @@ function getTaskStatus(task: ProxmoxTask): 'running' | 'completed' | 'failed' {
|
||||
function TaskStatusIcon({ status }: { status: 'running' | 'completed' | 'failed' }) {
|
||||
switch (status) {
|
||||
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':
|
||||
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':
|
||||
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' }) {
|
||||
const config = {
|
||||
running: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
running: 'border-info/25 bg-info/10 text-info',
|
||||
completed: 'border-success/25 bg-success/10 text-success',
|
||||
failed: 'border-destructive/25 bg-destructive/10 text-destructive',
|
||||
}
|
||||
|
||||
const label = {
|
||||
@@ -67,7 +70,7 @@ function TaskStatusBadge({ status }: { status: 'running' | 'completed' | 'failed
|
||||
|
||||
return (
|
||||
<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} />
|
||||
{label[status]}
|
||||
@@ -140,9 +143,9 @@ export function TaskList({ connectionId }: TaskListProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading tasks...</p>
|
||||
</div>
|
||||
<PageSkeleton filter>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,13 +163,13 @@ export function TaskList({ connectionId }: TaskListProps) {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-6 w-6" />
|
||||
<h2 className="text-2xl font-semibold">Tasks</h2>
|
||||
<ListTodo className="h-6 w-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Tasks</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{tasks?.length ?? 0} total tasks
|
||||
{runningCount > 0 && (
|
||||
<span className="ml-2 text-blue-500 font-medium">
|
||||
<span className="ml-2 font-medium text-info">
|
||||
({runningCount} running)
|
||||
</span>
|
||||
)}
|
||||
@@ -227,32 +230,24 @@ export function TaskList({ connectionId }: TaskListProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{tasks?.length === 0 ? (
|
||||
<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">
|
||||
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" />
|
||||
<p>No tasks match your filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
tasks?.length === 0 ? (
|
||||
<EmptyState icon={ListTodo} title="No tasks found" />
|
||||
) : (
|
||||
<EmptyState icon={Search} title="No tasks match your filters" />
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-8" />
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium 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-right font-medium text-muted-foreground">Duration</th>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">UPID</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Status</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -262,7 +257,7 @@ export function TaskList({ connectionId }: TaskListProps) {
|
||||
return (
|
||||
<Fragment key={task.upid}>
|
||||
<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)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
@@ -272,57 +267,57 @@ export function TaskList({ connectionId }: TaskListProps) {
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</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">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{task.type}
|
||||
</span>
|
||||
</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">
|
||||
<TaskStatusBadge status={taskStatus} />
|
||||
</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">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatTimestamp(task.starttime)}
|
||||
</div>
|
||||
</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)}
|
||||
</td>
|
||||
</tr>
|
||||
{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">
|
||||
<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>
|
||||
<span className="text-muted-foreground">UPID:</span>
|
||||
<p className="font-mono">{task.upid}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">UPID</span>
|
||||
<p className="mt-0.5 font-mono text-xs">{task.upid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Task ID:</span>
|
||||
<p className="font-mono">{task.id}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Task ID</span>
|
||||
<p className="mt-0.5 font-mono text-xs">{task.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">PID:</span>
|
||||
<p className="font-mono">{task.pid}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">PID</span>
|
||||
<p className="mt-0.5 font-mono text-xs tabular-nums">{task.pid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">PStart:</span>
|
||||
<p className="font-mono">{task.pstart}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">PStart</span>
|
||||
<p className="mt-0.5 font-mono text-xs tabular-nums">{task.pstart}</p>
|
||||
</div>
|
||||
{task.endtime && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<p>{formatTimestamp(task.endtime)}</p>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">End Time</span>
|
||||
<p className="mt-0.5 font-mono text-xs tabular-nums">{formatTimestamp(task.endtime)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.exitstatus && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Exit Status:</span>
|
||||
<p className={task.exitstatus === 'OK' ? 'text-green-500' : 'text-red-500'}>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Exit Status</span>
|
||||
<p className={cn('mt-0.5 font-mono text-xs', task.exitstatus === 'OK' ? 'text-success' : 'text-destructive')}>
|
||||
{task.exitstatus}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ListTodo, Play } from 'lucide-react'
|
||||
import { ListTodo, Loader2 } from 'lucide-react'
|
||||
import { useTasks } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxTask } from '@/types/proxmox'
|
||||
|
||||
@@ -27,20 +27,20 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
|
||||
return (
|
||||
<button
|
||||
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" />
|
||||
<span className="flex-1 text-left">
|
||||
{runningCount > 0 ? (
|
||||
<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>No tasks running</span>
|
||||
)}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<Play className="h-3 w-3 text-blue-500 animate-pulse" />
|
||||
<Loader2 className="h-3 w-3 text-info animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -4,15 +4,20 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-sm shadow-primary/25 hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
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',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow',
|
||||
'rounded-lg border bg-card text-card-foreground shadow-card',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +22,7 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
))
|
||||
@@ -34,7 +34,10 @@ const CardTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
className={cn(
|
||||
'font-semibold leading-none tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -56,7 +59,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ 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'
|
||||
|
||||
@@ -66,7 +69,7 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
className={cn('flex items-center p-5 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
'text-base font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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<
|
||||
|
||||
@@ -37,7 +37,7 @@ const ScrollBar = React.forwardRef<
|
||||
)}
|
||||
{...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>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -11,32 +11,32 @@ interface StatusBadgeProps {
|
||||
const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
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,
|
||||
},
|
||||
stopped: {
|
||||
label: 'Stopped',
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||
icon: XCircle,
|
||||
color: 'border-border bg-muted/60 text-muted-foreground',
|
||||
icon: Square,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
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,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+14
-10
@@ -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: {
|
||||
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: {
|
||||
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: {
|
||||
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: {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-4 shadow-lg',
|
||||
config.container
|
||||
'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.bar
|
||||
)}
|
||||
style={{
|
||||
animation: 'toast-slide-in 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<Icon className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<p className="text-sm flex-1">{toast.message}</p>
|
||||
<Icon className={cn('h-5 w-5 mt-0.5 shrink-0', config.iconColor)} />
|
||||
<p className="flex-1 text-sm">{toast.message}</p>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
@@ -49,9 +51,9 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading containers...</p>
|
||||
</div>
|
||||
<PageSkeleton filter>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Containers</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Containers</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{containers.length} total containers across the cluster
|
||||
</p>
|
||||
@@ -116,44 +118,36 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{containers.length === 0 ? (
|
||||
<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">
|
||||
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" />
|
||||
<p>No containers match your filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
containers.length === 0 ? (
|
||||
<EmptyState icon={Box} title="No containers found" />
|
||||
) : (
|
||||
<EmptyState icon={Search} title="No containers match your filters" />
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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-right font-medium 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 font-medium text-muted-foreground">Disk</th>
|
||||
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Uptime</th>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Memory</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContainers.map((container) => (
|
||||
<tr
|
||||
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' : ''
|
||||
}`}
|
||||
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">
|
||||
<StatusBadge status={container.status} />
|
||||
@@ -161,21 +155,21 @@ export function ContainerList({ connectionId, onContainerClick }: ContainerListP
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{container.node}
|
||||
<span className="font-mono">{container.node}</span>
|
||||
</div>
|
||||
</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
|
||||
</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'}
|
||||
<span className="text-muted-foreground"> / {formatBytes(container.maxmem)}</span>
|
||||
</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'}
|
||||
<span className="text-muted-foreground"> / {formatBytes(container.maxdisk)}</span>
|
||||
</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)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1
|
||||
|
||||
const handleStart = () => {
|
||||
startVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
startVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
@@ -89,11 +89,11 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
}
|
||||
|
||||
const handleSuspend = () => {
|
||||
suspendVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
suspendVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
}
|
||||
|
||||
const handleResume = () => {
|
||||
resumeVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
resumeVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
}
|
||||
|
||||
const executeConfirmAction = () => {
|
||||
@@ -101,13 +101,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
|
||||
switch (confirmAction.type) {
|
||||
case 'stop':
|
||||
stopVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
stopVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
break
|
||||
case 'shutdown':
|
||||
shutdownVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
shutdownVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
break
|
||||
case 'reboot':
|
||||
rebootVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
rebootVM.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type })
|
||||
break
|
||||
}
|
||||
|
||||
@@ -125,11 +125,13 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
</Button>
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
VMID {vm.vmid} · {vm.type.toUpperCase()} · {vm.node}
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
VMID <span className="font-mono tabular-nums">{vm.vmid}</span> ·{' '}
|
||||
<span className="font-mono">{vm.type.toUpperCase()}</span> ·{' '}
|
||||
<span className="font-mono">{vm.node}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +144,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleStart}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
<Play />
|
||||
Start
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -155,7 +157,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleShutdown}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Power className="h-4 w-4 mr-1" />
|
||||
<Power />
|
||||
Shutdown
|
||||
</Button>
|
||||
<Button
|
||||
@@ -164,7 +166,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleStop}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
<Square />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
@@ -173,7 +175,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleReboot}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RotateCw className="h-4 w-4 mr-1" />
|
||||
<RotateCw />
|
||||
Reboot
|
||||
</Button>
|
||||
</>
|
||||
@@ -186,7 +188,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleSuspend}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
<Pause />
|
||||
Suspend
|
||||
</Button>
|
||||
)}
|
||||
@@ -198,7 +200,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={handleResume}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<PlayCircle className="h-4 w-4 mr-1" />
|
||||
<PlayCircle />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
@@ -210,7 +212,7 @@ export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
onClick={() => setMigrateDialogOpen(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4 mr-1" />
|
||||
<ArrowRightLeft />
|
||||
Migrate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
@@ -50,9 +52,9 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
|
||||
if (vmsLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading VMs...</p>
|
||||
</div>
|
||||
<PageSkeleton filter>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</PageSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{vms?.length ?? 0} total VMs across the cluster
|
||||
</p>
|
||||
@@ -127,45 +129,37 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{filteredVMs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{vms?.length === 0 ? (
|
||||
<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">
|
||||
<Search className="h-8 w-8 mx-auto text-muted-foreground/50" />
|
||||
<p>No VMs match your filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
vms?.length === 0 ? (
|
||||
<EmptyState icon={Box} title="No VMs found" />
|
||||
) : (
|
||||
<EmptyState icon={Search} title="No VMs match your filters" />
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium 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 font-medium 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 font-medium text-muted-foreground">Uptime</th>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Name</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Node</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">CPU</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Disk</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredVMs.map((vm) => (
|
||||
<tr
|
||||
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' : ''
|
||||
}`}
|
||||
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">
|
||||
<StatusBadge status={vm.status} />
|
||||
@@ -173,7 +167,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{vm.node}
|
||||
<span className="font-mono">{vm.node}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -181,18 +175,18 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
{vm.type}
|
||||
</span>
|
||||
</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
|
||||
</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'}
|
||||
<span className="text-muted-foreground"> / {formatBytes(vm.maxmem)}</span>
|
||||
</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'}
|
||||
<span className="text-muted-foreground"> / {formatBytes(vm.maxdisk)}</span>
|
||||
</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)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -32,6 +32,7 @@ export function AddDiskDialog({ open, onOpenChange, vm }: AddDiskDialogProps) {
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
config: { storage, size: sizeBytes, busType },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
config: {
|
||||
bridge,
|
||||
model,
|
||||
@@ -114,7 +115,7 @@ export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
type="checkbox"
|
||||
checked={firewall}
|
||||
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">
|
||||
Enable firewall
|
||||
|
||||
@@ -31,6 +31,7 @@ export function CreateSnapshotDialog({ open, onOpenChange, vm }: CreateSnapshotD
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
config: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
@@ -83,7 +84,7 @@ export function CreateSnapshotDialog({ open, onOpenChange, vm }: CreateSnapshotD
|
||||
id="vmstate"
|
||||
checked={vmstate}
|
||||
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">
|
||||
Include VM state (memory)
|
||||
|
||||
@@ -33,6 +33,7 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
nic: nic.name,
|
||||
config: {
|
||||
bridge: bridge || undefined,
|
||||
@@ -100,7 +101,7 @@ export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProp
|
||||
type="checkbox"
|
||||
checked={firewall}
|
||||
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">
|
||||
Enable firewall
|
||||
|
||||
@@ -37,6 +37,7 @@ export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateD
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
targetNode,
|
||||
online,
|
||||
},
|
||||
@@ -60,10 +61,10 @@ export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateD
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-3 flex gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 shrink-0" />
|
||||
<div className="flex gap-2 rounded-md border border-warning/25 bg-warning/10 p-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||||
<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">
|
||||
Migration may cause temporary downtime. Online migration keeps the VM running but
|
||||
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"
|
||||
checked={online}
|
||||
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">
|
||||
Online migration (VM stays running)
|
||||
|
||||
@@ -30,6 +30,7 @@ export function MoveDiskDialog({ open, onOpenChange, vm, disk }: MoveDiskDialogP
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
disk: disk.device,
|
||||
storage: targetStorage,
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ResizeDiskDialog({ open, onOpenChange, vm, disk }: ResizeDiskDia
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
vmType: vm.type,
|
||||
disk: disk.device,
|
||||
size: sizeBytes,
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-[600px]">
|
||||
{/* 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">
|
||||
{isVM ? (
|
||||
<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">
|
||||
{isVM ? 'VNC Console' : 'Terminal Console'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
({vm.name} - {vm.type.toUpperCase()})
|
||||
</span>
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
onClick={handleRetry}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<RotateCw className="h-3 w-3 mr-1" />
|
||||
<RotateCw />
|
||||
Retry
|
||||
</Button>
|
||||
) : (
|
||||
@@ -168,7 +168,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
{/* Console Area */}
|
||||
<div
|
||||
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 */}
|
||||
{status === 'idle' && (
|
||||
@@ -183,7 +183,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
{isVM ? 'VNC Console' : 'Terminal Console'}
|
||||
</p>
|
||||
{!canConnect ? (
|
||||
<p className="text-xs text-amber-500">
|
||||
<p className="text-xs text-warning">
|
||||
VM must be running to access console
|
||||
</p>
|
||||
) : (
|
||||
@@ -214,7 +214,7 @@ export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
onClick={handleRetry}
|
||||
className="mt-2"
|
||||
>
|
||||
<RotateCw className="h-3 w-3 mr-1" />
|
||||
<RotateCw />
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { HardDrive, Plus, Pencil, Trash, ArrowRightLeft } from 'lucide-react'
|
||||
import { useDisks, useRemoveDisk } from '@/hooks/useProxmox'
|
||||
import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog'
|
||||
@@ -23,7 +18,7 @@ interface 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 [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
@@ -33,20 +28,20 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteDisk) return
|
||||
removeDisk.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, disk: deleteDisk.device },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteDisk(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
removeDisk.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, disk: deleteDisk.device })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading disks...</p>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -65,7 +60,7 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
{diskList.length} disk{diskList.length !== 1 ? 's' : ''} attached
|
||||
</p>
|
||||
@@ -79,40 +74,37 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{diskList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<HardDrive className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No disks attached</p>
|
||||
</div>
|
||||
<EmptyState icon={HardDrive} title="No disks attached" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium text-muted-foreground">Usage</th>
|
||||
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diskList.map((disk) => (
|
||||
<tr
|
||||
key={disk.device}
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<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">{disk.storage}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{disk.format}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{disk.usage ?? '-'}
|
||||
</td>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Size</th>
|
||||
<th className="h-10 px-4 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground">Storage</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Usage</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diskList.map((disk) => (
|
||||
<tr
|
||||
key={disk.device}
|
||||
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 tabular-nums">{formatBytes(disk.size)}</td>
|
||||
<td className="px-4 py-3 font-mono">{disk.storage}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{disk.format}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums text-muted-foreground">
|
||||
{disk.usage ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -183,28 +175,17 @@ export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteDisk} onOpenChange={(open) => { if (!open) setDeleteDisk(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Disk</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove {deleteDisk?.device} from {vm.name}? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDisk(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={removeDisk.isPending}
|
||||
>
|
||||
{removeDisk.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={!!deleteDisk}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteDisk(null)
|
||||
}}
|
||||
title="Remove Disk"
|
||||
description={`Are you sure you want to remove ${deleteDisk?.device} from ${vm.name}? This action cannot be undone.`}
|
||||
confirmLabel="Remove"
|
||||
isLoading={removeDisk.isPending}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Basic Configuration</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
<Pencil />
|
||||
Edit
|
||||
</Button>
|
||||
</CardHeader>
|
||||
@@ -54,11 +54,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">Tags</p>
|
||||
@@ -74,18 +74,18 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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" />
|
||||
<div>
|
||||
<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 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" />
|
||||
<div>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">BIOS</p>
|
||||
@@ -166,11 +166,11 @@ export function HardwareTab({ vm }: HardwareTabProps) {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
<X />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
<Save />
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { Network, Plus, Pencil, Trash } from 'lucide-react'
|
||||
import { useNetworkInterfaces, useRemoveNIC } from '@/hooks/useProxmox'
|
||||
import { AddNICDialog } from '@/components/vms/dialogs/AddNICDialog'
|
||||
@@ -22,7 +17,7 @@ interface 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 [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
@@ -31,20 +26,20 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteNic) return
|
||||
removeNIC.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, nic: deleteNic.name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteNic(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
removeNIC.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, nic: deleteNic.name })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading network interfaces...</p>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -63,10 +58,10 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
{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
|
||||
</span>
|
||||
</p>
|
||||
@@ -80,52 +75,49 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{nicList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No network interfaces attached</p>
|
||||
</div>
|
||||
<EmptyState icon={Network} title="No network interfaces attached" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium 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-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nicList.map((nic) => (
|
||||
<tr
|
||||
key={nic.name}
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono">{nic.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{nic.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{nic.macaddr}</td>
|
||||
<td className="px-4 py-3">{nic.bridge ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{nic.tag != null ? (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">{nic.tag}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{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-muted-foreground/30" title="Disabled" />
|
||||
)}
|
||||
</td>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Bridge</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Firewall</th>
|
||||
<th className="h-10 px-4 text-right text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nicList.map((nic) => (
|
||||
<tr
|
||||
key={nic.name}
|
||||
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">
|
||||
<span className="text-xs uppercase text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{nic.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums">{nic.macaddr}</td>
|
||||
<td className="px-4 py-3 font-mono">{nic.bridge ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{nic.tag != null ? (
|
||||
<span className="font-mono text-xs tabular-nums bg-muted px-1.5 py-0.5 rounded">{nic.tag}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{nic.firewall === 1 ? (
|
||||
<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" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -176,28 +168,17 @@ export function NetworkTab({ vm, connectionId }: NetworkTabProps) {
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteNic} onOpenChange={(open) => { if (!open) setDeleteNic(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Network Interface</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove {deleteNic?.name} from {vm.name}? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteNic(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={removeNIC.isPending}
|
||||
>
|
||||
{removeNIC.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={!!deleteNic}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteNic(null)
|
||||
}}
|
||||
title="Remove Network Interface"
|
||||
description={`Are you sure you want to remove ${deleteNic?.name} from ${vm.name}? This action cannot be undone.`}
|
||||
confirmLabel="Remove"
|
||||
isLoading={removeNIC.isPending}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
const displayUsed = label === 'CPU' ? used.toFixed(1) : formatBytes(used)
|
||||
const displayTotal = label === 'CPU' ? `${total} cores` : formatBytes(total)
|
||||
|
||||
const barColor =
|
||||
percent > 90 ? 'bg-destructive' :
|
||||
percent > 70 ? 'bg-warning' :
|
||||
'bg-success'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
{label}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
{displayUsed} / {displayTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(percent, 100)}%`,
|
||||
backgroundColor:
|
||||
percent > 90
|
||||
? 'hsl(var(--destructive))'
|
||||
: percent > 70
|
||||
? 'hsl(38, 92%, 50%)'
|
||||
: 'hsl(142, 71%, 45%)',
|
||||
}}
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</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)}%
|
||||
</p>
|
||||
</div>
|
||||
@@ -88,7 +85,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -98,7 +97,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -108,7 +109,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -118,7 +121,9 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
@@ -128,8 +133,10 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">192.168.1.{100 + (vm.vmid % 100)}</div>
|
||||
<p className="text-xs text-muted-foreground">Mock data</p>
|
||||
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
|
||||
192.168.1.{100 + (vm.vmid % 100)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Mock data</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -142,11 +149,11 @@ export function OverviewTab({ vm }: OverviewTabProps) {
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<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 className="flex justify-between text-sm">
|
||||
<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>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { useSnapshots, useDeleteSnapshot, useRollbackSnapshot } from '@/hooks/useProxmox'
|
||||
import { CreateSnapshotDialog } from '@/components/vms/dialogs/CreateSnapshotDialog'
|
||||
@@ -24,7 +27,7 @@ function formatDate(timestamp: number): string {
|
||||
}
|
||||
|
||||
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 rollbackSnapshot = useRollbackSnapshot()
|
||||
|
||||
@@ -34,20 +37,13 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteTarget) return
|
||||
deleteSnapshot.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, name: deleteTarget.name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
deleteSnapshot.mutate({ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: deleteTarget.name })
|
||||
}
|
||||
|
||||
const handleRollback = () => {
|
||||
if (!rollbackTarget) return
|
||||
rollbackSnapshot.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, name: rollbackTarget.name },
|
||||
{ node: vm.node, vmid: vm.vmid, vmType: vm.type, name: rollbackTarget.name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRollbackTarget(null)
|
||||
@@ -58,8 +54,15 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading snapshots...</p>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +81,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
{snapshotList.length} snapshot{snapshotList.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
@@ -92,51 +95,51 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{snapshotList.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Camera className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p>No snapshots</p>
|
||||
<p className="text-xs mt-1">Create a snapshot to save the current VM state</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Camera}
|
||||
title="No snapshots"
|
||||
description="Create a snapshot to save the current VM state"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left font-medium 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 font-medium 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 font-medium text-muted-foreground">Parent</th>
|
||||
<th className="h-10 px-4 text-right font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshotList.map((snapshot) => (
|
||||
<tr
|
||||
key={snapshot.name}
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono font-medium">{snapshot.name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{snapshot.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{formatDate(snapshot.snaptime)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${
|
||||
snapshot.vmstate === 1
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{snapshot.vmstate === 1 ? 'Included' : 'Not included'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
|
||||
{snapshot.parent ?? '-'}
|
||||
</td>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Description</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">VM State</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 text-xs font-medium uppercase tracking-wide text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshotList.map((snapshot) => (
|
||||
<tr
|
||||
key={snapshot.name}
|
||||
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 text-muted-foreground">
|
||||
{snapshot.description || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatDate(snapshot.snaptime)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-xs font-medium ${
|
||||
snapshot.vmstate === 1
|
||||
? 'border-success/25 bg-success/10 text-success'
|
||||
: 'border-border bg-muted/60 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{snapshot.vmstate === 1 ? 'Included' : 'Not included'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
|
||||
{snapshot.parent ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -176,28 +179,17 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Snapshot</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete snapshot "{deleteTarget?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteSnapshot.isPending}
|
||||
>
|
||||
{deleteSnapshot.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
title="Delete Snapshot"
|
||||
description={`Are you sure you want to delete snapshot "${deleteTarget?.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
isLoading={deleteSnapshot.isPending}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Rollback confirmation */}
|
||||
<Dialog open={!!rollbackTarget} onOpenChange={(open) => { if (!open) setRollbackTarget(null) }}>
|
||||
@@ -207,7 +199,7 @@ export function SnapshotsTab({ vm, connectionId }: SnapshotsTabProps) {
|
||||
<DialogDescription>
|
||||
Are you sure you want to rollback {vm.name} to snapshot "{rollbackTarget?.name}"?
|
||||
{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.
|
||||
</span>
|
||||
)}
|
||||
|
||||
+74
-29
@@ -92,11 +92,12 @@ export const useStartVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.startVM(connId, node, vmid)
|
||||
return api.startVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -113,11 +114,12 @@ export const useStopVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.stopVM(connId, node, vmid)
|
||||
return api.stopVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM stopped', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -134,11 +136,12 @@ export const useShutdownVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.shutdownVM(connId, node, vmid)
|
||||
return api.shutdownVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM shut down', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -155,11 +158,12 @@ export const useRebootVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.rebootVM(connId, node, vmid)
|
||||
return api.rebootVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM rebooting', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -176,11 +180,12 @@ export const useSuspendVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.suspendVM(connId, node, vmid)
|
||||
return api.suspendVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM suspended', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -197,11 +202,12 @@ export const useResumeVM = () => {
|
||||
const { addToast } = useToast()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ node, vmid }: { node: string; vmid: number }) => {
|
||||
mutationFn: ({ node, vmid, vmType }: { node: string; vmid: number; vmType: string }) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.resumeVM(connId, node, vmid)
|
||||
return api.resumeVM(connId, node, vmid, vmType)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('VM resumed', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -214,10 +220,10 @@ export const useResumeVM = () => {
|
||||
}
|
||||
|
||||
// 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({
|
||||
queryKey: queryKeys.disks(connectionId, node, vmid),
|
||||
queryFn: () => api.getDisks(connectionId, node, vmid),
|
||||
queryFn: () => api.getDisks(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
}
|
||||
@@ -230,16 +236,19 @@ export const useAddDisk = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
config,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
config: AddDiskConfig
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.addDisk(connId, node, vmid, config)
|
||||
return api.addDisk(connId, node, vmid, vmType, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Disk added', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -261,18 +270,21 @@ export const useResizeDisk = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
disk,
|
||||
size,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
disk: string
|
||||
size: number
|
||||
}) => {
|
||||
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) => {
|
||||
addToast('Disk resized', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -294,16 +306,19 @@ export const useRemoveDisk = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
disk,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
disk: string
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.removeDisk(connId, node, vmid, disk)
|
||||
return api.removeDisk(connId, node, vmid, vmType, disk)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Disk removed', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -325,18 +340,21 @@ export const useMoveDisk = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
disk,
|
||||
storage,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
disk: string
|
||||
storage: string
|
||||
}) => {
|
||||
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) => {
|
||||
addToast('Disk moved', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -351,10 +369,10 @@ export const useMoveDisk = () => {
|
||||
}
|
||||
|
||||
// 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({
|
||||
queryKey: queryKeys.networks(connectionId, node, vmid),
|
||||
queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid),
|
||||
queryFn: () => api.getNetworkInterfaces(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
}
|
||||
@@ -367,16 +385,19 @@ export const useAddNIC = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
config,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
config: AddNICConfig
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.addNIC(connId, node, vmid, config)
|
||||
return api.addNIC(connId, node, vmid, vmType, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Network interface added', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -398,18 +419,21 @@ export const useEditNIC = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
nic,
|
||||
config,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
nic: string
|
||||
config: EditNICConfig
|
||||
}) => {
|
||||
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) => {
|
||||
addToast('Network interface updated', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -431,16 +455,19 @@ export const useRemoveNIC = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
nic,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
nic: string
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.removeNIC(connId, node, vmid, nic)
|
||||
return api.removeNIC(connId, node, vmid, vmType, nic)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Network interface removed', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -455,10 +482,10 @@ export const useRemoveNIC = () => {
|
||||
}
|
||||
|
||||
// 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({
|
||||
queryKey: queryKeys.snapshots(connectionId, node, vmid),
|
||||
queryFn: () => api.getSnapshots(connectionId, node, vmid),
|
||||
queryFn: () => api.getSnapshots(connectionId, node, vmid, vmType),
|
||||
enabled: !!connectionId && !!node && vmid > 0,
|
||||
})
|
||||
}
|
||||
@@ -471,16 +498,19 @@ export const useCreateSnapshot = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
config,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
config: CreateSnapshotConfig
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.createSnapshot(connId, node, vmid, config)
|
||||
return api.createSnapshot(connId, node, vmid, vmType, config)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Snapshot created', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -502,16 +532,19 @@ export const useDeleteSnapshot = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
name,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
name: string
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.deleteSnapshot(connId, node, vmid, name)
|
||||
return api.deleteSnapshot(connId, node, vmid, vmType, name)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Snapshot deleted', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -533,16 +566,19 @@ export const useRollbackSnapshot = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
name,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
name: string
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.rollbackSnapshot(connId, node, vmid, name)
|
||||
return api.rollbackSnapshot(connId, node, vmid, vmType, name)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
addToast('Snapshot restored', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -565,18 +601,21 @@ export const useMigrateVM = () => {
|
||||
mutationFn: ({
|
||||
node,
|
||||
vmid,
|
||||
vmType,
|
||||
targetNode,
|
||||
online,
|
||||
}: {
|
||||
node: string
|
||||
vmid: number
|
||||
vmType: string
|
||||
targetNode: string
|
||||
online: boolean
|
||||
}) => {
|
||||
const connId = useConnectionStore.getState().activeConnectionId!
|
||||
return api.migrateVM(connId, node, vmid, targetNode, online)
|
||||
return api.migrateVM(connId, node, vmid, vmType, targetNode, online)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Migration started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -634,6 +673,7 @@ export const useCreateBackupJob = () => {
|
||||
return api.createBackupJob(connId, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Backup job created', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
@@ -655,6 +695,7 @@ export const useUpdateBackupJob = () => {
|
||||
return api.updateBackupJob(connId, id, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Backup job updated', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
@@ -676,6 +717,7 @@ export const useDeleteBackupJob = () => {
|
||||
return api.deleteBackupJob(connId, id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Backup job deleted', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backupJobs(connId) })
|
||||
@@ -697,6 +739,7 @@ export const useRunBackup = () => {
|
||||
return api.runBackup(connId, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Backup started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tasks(connId) })
|
||||
@@ -718,6 +761,7 @@ export const useRestoreBackup = () => {
|
||||
return api.restoreBackup(connId, volid, config)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Restore started', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connId) })
|
||||
@@ -740,6 +784,7 @@ export const useDeleteBackup = () => {
|
||||
return api.deleteBackup(connId, volid)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addToast('Backup deleted', 'success')
|
||||
const connId = useConnectionStore.getState().activeConnectionId
|
||||
if (connId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.backups(connId) })
|
||||
|
||||
+149
-39
@@ -1,56 +1,166 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.145 0 0);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0 0);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.97 0 0);
|
||||
--color-secondary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--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);
|
||||
/* ------------------------------------------------------------------
|
||||
* Typography — offline-safe variable fonts (bundled via fontsource)
|
||||
* ------------------------------------------------------------------ */
|
||||
--font-sans:
|
||||
'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
|
||||
'Segoe UI', sans-serif;
|
||||
--font-mono:
|
||||
'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
|
||||
Menlo, monospace;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Radius scale — tighter on inner elements, softer on containers
|
||||
* ------------------------------------------------------------------ */
|
||||
--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 {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-card: oklch(0.145 0 0);
|
||||
--color-card-foreground: oklch(0.985 0 0);
|
||||
--color-popover: oklch(0.145 0 0);
|
||||
--color-popover-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.985 0 0);
|
||||
--color-primary-foreground: oklch(0.205 0 0);
|
||||
--color-secondary: oklch(0.269 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.269 0 0);
|
||||
--color-muted-foreground: oklch(0.708 0 0);
|
||||
--color-accent: oklch(0.269 0 0);
|
||||
--color-accent-foreground: oklch(0.985 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.269 0 0);
|
||||
--color-input: oklch(0.269 0 0);
|
||||
--color-ring: oklch(0.439 0 0);
|
||||
/* ------------------------------------------------------------------
|
||||
* Dark theme — blue-black ink surfaces, cool neutrals, muted orange
|
||||
* ------------------------------------------------------------------ */
|
||||
--color-background: oklch(0.16 0.011 250);
|
||||
--color-foreground: oklch(0.965 0.004 250);
|
||||
--color-card: oklch(0.19 0.012 250);
|
||||
--color-card-foreground: oklch(0.965 0.004 250);
|
||||
--color-popover: oklch(0.215 0.013 250);
|
||||
--color-popover-foreground: oklch(0.965 0.004 250);
|
||||
--color-primary: oklch(0.72 0.15 60);
|
||||
--color-primary-foreground: oklch(0.18 0.02 50);
|
||||
--color-secondary: oklch(0.235 0.011 250);
|
||||
--color-secondary-foreground: oklch(0.94 0.004 250);
|
||||
--color-muted: oklch(0.235 0.011 250);
|
||||
--color-muted-foreground: oklch(0.655 0.012 250);
|
||||
--color-accent: oklch(0.275 0.012 250);
|
||||
--color-accent-foreground: oklch(0.965 0.004 250);
|
||||
--color-destructive: oklch(0.645 0.18 25);
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
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
@@ -1,7 +1,14 @@
|
||||
// Tauri IPC commands interface
|
||||
// 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 {
|
||||
ProxmoxNode,
|
||||
ProxmoxVM,
|
||||
@@ -36,6 +43,14 @@ const mockResponse = <T>(data: T): Promise<T> => {
|
||||
}
|
||||
|
||||
// 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> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 })
|
||||
}
|
||||
|
||||
export const connectToServer = async (id: string): Promise<void> => {
|
||||
export const updateConnection = async (config: ConnectionConfig): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 })
|
||||
}
|
||||
|
||||
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> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
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
|
||||
export const loginWithPassword = async (
|
||||
url: string,
|
||||
@@ -222,40 +267,40 @@ export const getClusterStatus = async (connectionId: string): Promise<ProxmoxClu
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
@@ -263,6 +308,7 @@ export const getDisks = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
): Promise<ProxmoxDisk[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
@@ -271,53 +317,57 @@ export const getDisks = async (
|
||||
])
|
||||
}
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
config: AddDiskConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
disk: string,
|
||||
size: number,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
disk: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
disk: string,
|
||||
storage: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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
|
||||
@@ -325,6 +375,7 @@ export const getNetworkInterfaces = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
): Promise<ProxmoxNetwork[]> => {
|
||||
if (!isTauri()) {
|
||||
return mockResponse([
|
||||
@@ -332,41 +383,44 @@ export const getNetworkInterfaces = async (
|
||||
])
|
||||
}
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
config: AddNICConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
nic: string,
|
||||
config: EditNICConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
nic: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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
|
||||
@@ -374,43 +428,47 @@ export const getSnapshots = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
): Promise<ProxmoxSnapshot[]> => {
|
||||
if (!isTauri()) return mockResponse([])
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
config: CreateSnapshotConfig,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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 (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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
|
||||
@@ -418,12 +476,13 @@ export const migrateVM = async (
|
||||
connectionId: string,
|
||||
node: string,
|
||||
vmid: number,
|
||||
vmType: string,
|
||||
targetNode: string,
|
||||
online: boolean,
|
||||
): Promise<void> => {
|
||||
if (!isTauri()) return mockResponse(undefined)
|
||||
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
|
||||
@@ -565,3 +624,12 @@ export const deleteBackup = async (connectionId: string, volid: string): Promise
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@fontsource-variable/geist'
|
||||
import '@fontsource-variable/jetbrains-mono'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -14,8 +14,10 @@ interface ConnectionState {
|
||||
addConnection: (config: ConnectionConfig) => void
|
||||
removeConnection: (id: string) => void
|
||||
updateConnection: (id: string, updates: Partial<ConnectionConfig>) => void
|
||||
hydrate: (connections: ConnectionConfig[], activeConnectionId: string | null) => void
|
||||
setActiveConnection: (id: string | null) => void
|
||||
setConnectionStatus: (id: string, status: ConnectionStatus) => void
|
||||
setConnectionNodes: (id: string, nodes: DiscoveredNode[]) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
setAuthStatus: (status: AuthStatus) => void
|
||||
@@ -47,6 +49,9 @@ export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
hydrate: (connections, activeConnectionId) =>
|
||||
set({ connections, activeConnectionId }),
|
||||
|
||||
setActiveConnection: (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 }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
@@ -9,11 +9,47 @@ export interface ConnectionConfig {
|
||||
fallbacks: EndpointConfig[]
|
||||
certFingerprint?: string
|
||||
trusted: boolean
|
||||
acceptUntrusted?: boolean
|
||||
status: ConnectionStatus
|
||||
clusterName?: string
|
||||
clusterId?: string
|
||||
isCluster: boolean
|
||||
authMode: AuthMode
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user