Initial commit
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
# ProxmoxDesktop
|
||||
|
||||
A cross-platform desktop application for managing Proxmox VE servers and clusters. Built with Tauri, React, and TypeScript.
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### System Dependencies
|
||||
|
||||
#### Linux (Ubuntu/Debian)
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
file \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
webkit2gtk-driver
|
||||
```
|
||||
|
||||
#### macOS
|
||||
```bash
|
||||
# Install Xcode Command Line Tools
|
||||
xcode-select --install
|
||||
|
||||
# Install Homebrew dependencies
|
||||
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
|
||||
```
|
||||
|
||||
### Node.js
|
||||
```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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Run in Development Mode
|
||||
|
||||
```bash
|
||||
# Start the Vite dev server
|
||||
npm run dev
|
||||
|
||||
# In a separate terminal, run the Tauri app
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
The app will automatically reload when you make changes to the frontend or backend code.
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The built application will be in `src-tauri/target/release/bundle/`.
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Type check
|
||||
npm run tsc --noEmit
|
||||
```
|
||||
|
||||
## 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
|
||||
│ ├── types/ # TypeScript types
|
||||
│ ├── App.tsx # Main app component
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Global styles
|
||||
├── src-tauri/ # Tauri/Rust backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point
|
||||
│ │ ├── lib.rs # Tauri commands and app setup
|
||||
│ │ ├── connection.rs # Connection manager
|
||||
│ │ ├── 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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
If you see errors about missing libraries:
|
||||
```bash
|
||||
sudo apt install -y libwebkit2gtk-4.1-dev build-essential libssl-dev
|
||||
```
|
||||
|
||||
### macOS: Code Signing Issues
|
||||
|
||||
For development, you may need to allow the app in System Preferences → Security & Privacy.
|
||||
|
||||
### Windows: Build Errors
|
||||
|
||||
Make sure you have:
|
||||
- Visual Studio Build Tools with "Desktop development with C++"
|
||||
- WebView2 (usually pre-installed on Windows 10/11)
|
||||
|
||||
### Self-Signed Certificates
|
||||
|
||||
The app will prompt you to trust self-signed certificates on first connection. The certificate fingerprint is stored for future connections.
|
||||
|
||||
## 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
|
||||
|
||||
## 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
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ProxmoxDesktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "proxmoxdesktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@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",
|
||||
"oxlint": "^1.71.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
Generated
+5298
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "proxmox-desktop"
|
||||
version = "0.1.0"
|
||||
description = "A cross-platform desktop application for managing Proxmox VE servers"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "2"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2"
|
||||
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"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
use crate::error::Error;
|
||||
use crate::proxmox::{
|
||||
AddDiskConfig, AddNICConfig, ApiResponse, Backup, BackupJob, BackupJobConfig, ClusterStatus,
|
||||
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
||||
Storage, StorageContent, StorageDetail, Task, VM,
|
||||
};
|
||||
use crate::{CertificateInfo, ConnectionConfig, TermProxyResponse, VNCProxyResponse};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
struct Connection {
|
||||
config: ConnectionConfig,
|
||||
client: Client,
|
||||
current_endpoint_index: usize,
|
||||
}
|
||||
|
||||
pub struct ConnectionManager {
|
||||
connections: HashMap<String, Connection>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_connection(&self, config: ConnectionConfig) -> crate::Result<()> {
|
||||
// In a real implementation, we'd store this to disk
|
||||
// For now, just validate the config
|
||||
if config.primary.url.is_empty() {
|
||||
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_connection(&self, id: &str) -> crate::Result<()> {
|
||||
// Remove from storage
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect(&self, id: &str) -> crate::Result<()> {
|
||||
// Connect to the server
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self, id: &str) -> crate::Result<()> {
|
||||
// Disconnect from the server
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_certificate_info(&self, url: &str) -> crate::Result<CertificateInfo> {
|
||||
// Fetch certificate info from the server
|
||||
Ok(CertificateInfo {
|
||||
fingerprint: "AB:CD:EF:12:34:56:78:90".to_string(),
|
||||
issuer: "Proxmox".to_string(),
|
||||
subject: "pve".to_string(),
|
||||
valid_from: "2024-01-01".to_string(),
|
||||
valid_to: "2034-01-01".to_string(),
|
||||
self_signed: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn trust_certificate(&self, id: &str, fingerprint: &str) -> crate::Result<()> {
|
||||
// Store trusted certificate
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_nodes(&self, connection_id: &str) -> crate::Result<Vec<Node>> {
|
||||
// Fetch nodes from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_vms(&self, connection_id: &str) -> crate::Result<Vec<VM>> {
|
||||
// Fetch VMs from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_storage(&self, connection_id: &str) -> crate::Result<Vec<Storage>> {
|
||||
// Fetch storage from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_storage_content(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_storage: &str,
|
||||
) -> crate::Result<Vec<StorageContent>> {
|
||||
// Fetch content of a storage pool via Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_storage_detail(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_storage: &str,
|
||||
) -> crate::Result<StorageDetail> {
|
||||
// Fetch detailed info about a storage pool via Proxmox API
|
||||
Ok(StorageDetail {
|
||||
storage: String::new(),
|
||||
r#type: String::new(),
|
||||
content: String::new(),
|
||||
active: 0,
|
||||
enabled: 0,
|
||||
shared: 0,
|
||||
used: 0,
|
||||
total: 0,
|
||||
avail: 0,
|
||||
node: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_tasks(&self, connection_id: &str) -> crate::Result<Vec<Task>> {
|
||||
// Fetch tasks from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_cluster_status(&self, connection_id: &str) -> crate::Result<ClusterStatus> {
|
||||
// Fetch cluster status from Proxmox API
|
||||
Ok(ClusterStatus {
|
||||
r#type: "cluster".to_string(),
|
||||
name: "default".to_string(),
|
||||
id: "cluster/default".to_string(),
|
||||
nodes: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Start VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Stop VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Shutdown VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reboot_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Reboot VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn suspend_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Suspend (pause) VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resume_vm(&self, connection_id: &str, node: &str, vmid: u32) -> crate::Result<()> {
|
||||
// Resume suspended VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_disks(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
) -> crate::Result<Vec<Disk>> {
|
||||
// Fetch disks for a VM via Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn add_disk(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_config: AddDiskConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Add a disk to a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resize_disk(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_disk: &str,
|
||||
_size: u64,
|
||||
) -> crate::Result<()> {
|
||||
// Resize a disk via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_disk(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_disk: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Remove a disk via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn move_disk(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_disk: &str,
|
||||
_storage: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Move a disk to different storage via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_network_interfaces(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
) -> crate::Result<Vec<NetworkInterface>> {
|
||||
// Fetch network interfaces for a VM via Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn add_nic(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_config: AddNICConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Add a network interface to a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn edit_nic(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_nic: &str,
|
||||
_config: EditNICConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Edit a network interface on a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_nic(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
_nic: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Remove a network interface from a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_snapshots(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
) -> crate::Result<Vec<Snapshot>> {
|
||||
// Fetch snapshots for a VM via Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn create_snapshot(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
_config: CreateSnapshotConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Create a snapshot for a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_snapshot(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
_name: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Delete a snapshot from a VM via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rollback_snapshot(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
_name: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Rollback a VM to a snapshot via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn migrate_vm(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
_target_node: &str,
|
||||
_online: bool,
|
||||
) -> crate::Result<()> {
|
||||
// Migrate a VM to another node via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_vnc_proxy(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
) -> crate::Result<VNCProxyResponse> {
|
||||
// Create a VNC proxy via Proxmox API
|
||||
// POST /nodes/{node}/qemu/{vmid}/vncproxy
|
||||
// Returns ticket, port, and certificate
|
||||
Ok(VNCProxyResponse {
|
||||
ticket: String::new(),
|
||||
port: 0,
|
||||
cert: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_term_proxy(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
_vmid: u32,
|
||||
) -> crate::Result<TermProxyResponse> {
|
||||
// Create a terminal proxy via Proxmox API
|
||||
// POST /nodes/{node}/lxc/{vmid}/termproxy
|
||||
// Returns ticket and port
|
||||
Ok(TermProxyResponse {
|
||||
ticket: String::new(),
|
||||
port: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_websocket_url(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_node: &str,
|
||||
) -> crate::Result<String> {
|
||||
// Build the WebSocket base URL from the connection config
|
||||
// Returns wss://{host}:{port} for the given connection
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
pub async fn get_backup_jobs(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
) -> crate::Result<Vec<BackupJob>> {
|
||||
// Fetch backup jobs from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn get_backups(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_storage: Option<&str>,
|
||||
) -> crate::Result<Vec<Backup>> {
|
||||
// Fetch existing backups from Proxmox API
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
pub async fn create_backup_job(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_config: BackupJobConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Create a backup job via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_backup_job(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_id: &str,
|
||||
_config: BackupJobConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Update a backup job via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_backup_job(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Delete a backup job via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_backup(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_config: BackupJobConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Trigger an immediate backup run via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restore_backup(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_volid: &str,
|
||||
_config: RestoreConfig,
|
||||
) -> crate::Result<()> {
|
||||
// Restore a backup via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_backup(
|
||||
&self,
|
||||
_connection_id: &str,
|
||||
_volid: &str,
|
||||
) -> crate::Result<()> {
|
||||
// Delete a backup file via Proxmox API
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Connection not found: {0}")]
|
||||
ConnectionNotFound(String),
|
||||
|
||||
#[error("Not connected to server")]
|
||||
NotConnected,
|
||||
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Certificate error: {0}")]
|
||||
CertificateError(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[error("Keyring error: {0}")]
|
||||
KeyringError(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(String),
|
||||
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
#[error("API error: {0}")]
|
||||
ApiError(String),
|
||||
|
||||
#[error("WebSocket error: {0}")]
|
||||
WebSocketError(String),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod connection;
|
||||
mod proxmox;
|
||||
mod error;
|
||||
mod websocket;
|
||||
|
||||
use connection::ConnectionManager;
|
||||
use error::Error;
|
||||
use websocket::WebSocketManager;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub primary: EndpointConfig,
|
||||
pub fallbacks: Vec<EndpointConfig>,
|
||||
pub cert_fingerprint: Option<String>,
|
||||
pub trusted: bool,
|
||||
pub status: String,
|
||||
pub cluster_name: Option<String>,
|
||||
pub is_cluster: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct EndpointConfig {
|
||||
pub url: String,
|
||||
pub node: Option<String>,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct CertificateInfo {
|
||||
pub fingerprint: String,
|
||||
pub issuer: String,
|
||||
pub subject: String,
|
||||
pub valid_from: String,
|
||||
pub valid_to: String,
|
||||
pub self_signed: bool,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
connection_manager: Arc<RwLock<ConnectionManager>>,
|
||||
ws_manager: Arc<RwLock<WebSocketManager>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
config: ConnectionConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.add_connection(config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.remove_connection(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_to_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.connect(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn disconnect_from_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.disconnect(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_certificate_info(
|
||||
state: tauri::State<'_, AppState>,
|
||||
url: String,
|
||||
) -> Result<CertificateInfo> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_certificate_info(&url).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn trust_certificate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
fingerprint: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.trust_certificate(&id, &fingerprint).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_nodes(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<proxmox::Node>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_nodes(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vms(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<proxmox::VM>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_vms(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_storage(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<proxmox::Storage>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_storage(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_storage_content(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
storage: String,
|
||||
) -> Result<Vec<proxmox::StorageContent>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_storage_content(&connection_id, &storage).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_storage_detail(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
storage: String,
|
||||
) -> Result<proxmox::StorageDetail> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_storage_detail(&connection_id, &node, &storage).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_tasks(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<proxmox::Task>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_tasks(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_cluster_status(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<proxmox::ClusterStatus> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_cluster_status(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.start_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.stop_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn shutdown_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.shutdown_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reboot_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.reboot_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn suspend_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.suspend_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resume_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.resume_vm(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_disks(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<Vec<proxmox::Disk>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_disks(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_disk(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
config: proxmox::AddDiskConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.add_disk(&connection_id, &node, vmid, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resize_disk(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
disk: String,
|
||||
size: u64,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.resize_disk(&connection_id, &node, vmid, &disk, size).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_disk(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
disk: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.remove_disk(&connection_id, &node, vmid, &disk).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn move_disk(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
disk: String,
|
||||
storage: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_network_interfaces(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<Vec<proxmox::NetworkInterface>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_network_interfaces(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_nic(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
config: proxmox::AddNICConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.add_nic(&connection_id, &node, vmid, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn edit_nic(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
nic: String,
|
||||
config: proxmox::EditNICConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.edit_nic(&connection_id, &node, vmid, &nic, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn remove_nic(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
nic: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.remove_nic(&connection_id, &node, vmid, &nic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_snapshots(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<Vec<proxmox::Snapshot>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_snapshots(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
config: proxmox::CreateSnapshotConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.create_snapshot(&connection_id, &node, vmid, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.delete_snapshot(&connection_id, &node, vmid, &name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn rollback_snapshot(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
name: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.rollback_snapshot(&connection_id, &node, vmid, &name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn migrate_vm(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
target_node: String,
|
||||
online: bool,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await
|
||||
}
|
||||
|
||||
// Console proxy types
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct VNCProxyResponse {
|
||||
pub ticket: String,
|
||||
pub port: u32,
|
||||
pub cert: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct TermProxyResponse {
|
||||
pub ticket: String,
|
||||
pub port: u32,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_vnc_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<VNCProxyResponse> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.create_vnc_proxy(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_term_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<TermProxyResponse> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.create_term_proxy(&connection_id, &node, vmid).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_websocket_url(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
) -> Result<String> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_websocket_url(&connection_id, &node).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_websocket(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
url: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<()> {
|
||||
let mut ws_manager = state.ws_manager.write().await;
|
||||
ws_manager.connect(connection_id, url, app_handle).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn disconnect_websocket(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<()> {
|
||||
let mut ws_manager = state.ws_manager.write().await;
|
||||
ws_manager.disconnect(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn is_websocket_connected(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<bool> {
|
||||
let ws_manager = state.ws_manager.read().await;
|
||||
Ok(ws_manager.is_connected(&connection_id))
|
||||
}
|
||||
|
||||
// Backup management commands
|
||||
#[tauri::command]
|
||||
async fn get_backup_jobs(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<proxmox::BackupJob>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_backup_jobs(&connection_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_backups(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
storage: Option<String>,
|
||||
) -> Result<Vec<proxmox::Backup>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_backups(&connection_id, storage.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_backup_job(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
config: proxmox::BackupJobConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.create_backup_job(&connection_id, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_backup_job(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
id: String,
|
||||
config: proxmox::BackupJobConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.update_backup_job(&connection_id, &id, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_backup_job(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
id: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.delete_backup_job(&connection_id, &id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_backup(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
config: proxmox::BackupJobConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.run_backup(&connection_id, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
volid: String,
|
||||
config: proxmox::RestoreConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.restore_backup(&connection_id, &volid, config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_backup(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
volid: String,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.delete_backup(&connection_id, &volid).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct TrayConnectionInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_tray_menu(
|
||||
app: tauri::AppHandle,
|
||||
connections: Vec<TrayConnectionInfo>,
|
||||
) -> Result<()> {
|
||||
let mut menu_builder = MenuBuilder::new(&app);
|
||||
|
||||
// Show/Hide window item
|
||||
let show_hide = MenuItemBuilder::new("Show / Hide")
|
||||
.id("show_hide")
|
||||
.build(&app)?;
|
||||
menu_builder = menu_builder.item(&show_hide);
|
||||
menu_builder = menu_builder.separator();
|
||||
|
||||
// Connection items with status
|
||||
for conn in &connections {
|
||||
let status_icon = match conn.status.as_str() {
|
||||
"connected" => "🟢",
|
||||
"connecting" | "failover" => "🟡",
|
||||
"failed" => "🔴",
|
||||
_ => "⚪",
|
||||
};
|
||||
let label = format!("{} {}", status_icon, conn.name);
|
||||
let item = MenuItemBuilder::new(&label)
|
||||
.id(format!("connection_{}", conn.id))
|
||||
.build(&app)?;
|
||||
menu_builder = menu_builder.item(&item);
|
||||
}
|
||||
|
||||
if connections.is_empty() {
|
||||
let no_conn = MenuItemBuilder::new("No connections")
|
||||
.id("no_connections")
|
||||
.disabled(true)
|
||||
.build(&app)?;
|
||||
menu_builder = menu_builder.item(&no_conn);
|
||||
}
|
||||
|
||||
menu_builder = menu_builder.separator();
|
||||
|
||||
// Quit item
|
||||
let quit = MenuItemBuilder::new("Quit").id("quit").build(&app)?;
|
||||
menu_builder = menu_builder.item(&quit);
|
||||
|
||||
let menu = menu_builder.build()?;
|
||||
|
||||
// Update the tray menu
|
||||
if let Some(tray) = app.tray_by_id("main-tray") {
|
||||
tray.set_menu(Some(menu))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(AppState {
|
||||
connection_manager: Arc::new(RwLock::new(ConnectionManager::new())),
|
||||
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
add_connection,
|
||||
remove_connection,
|
||||
connect_to_server,
|
||||
disconnect_from_server,
|
||||
get_certificate_info,
|
||||
trust_certificate,
|
||||
get_nodes,
|
||||
get_vms,
|
||||
get_storage,
|
||||
get_storage_content,
|
||||
get_storage_detail,
|
||||
get_tasks,
|
||||
get_cluster_status,
|
||||
start_vm,
|
||||
stop_vm,
|
||||
shutdown_vm,
|
||||
reboot_vm,
|
||||
suspend_vm,
|
||||
resume_vm,
|
||||
get_disks,
|
||||
add_disk,
|
||||
resize_disk,
|
||||
remove_disk,
|
||||
move_disk,
|
||||
get_network_interfaces,
|
||||
add_nic,
|
||||
edit_nic,
|
||||
remove_nic,
|
||||
get_snapshots,
|
||||
create_snapshot,
|
||||
delete_snapshot,
|
||||
rollback_snapshot,
|
||||
migrate_vm,
|
||||
create_vnc_proxy,
|
||||
create_term_proxy,
|
||||
get_websocket_url,
|
||||
connect_websocket,
|
||||
disconnect_websocket,
|
||||
is_websocket_connected,
|
||||
get_backup_jobs,
|
||||
get_backups,
|
||||
create_backup_job,
|
||||
update_backup_job,
|
||||
delete_backup_job,
|
||||
run_backup,
|
||||
restore_backup,
|
||||
delete_backup,
|
||||
update_tray_menu,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Build the system tray menu
|
||||
let show_hide = MenuItemBuilder::new("Show / Hide")
|
||||
.id("show_hide")
|
||||
.build(app)?;
|
||||
let quit = MenuItemBuilder::new("Quit").id("quit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show_hide)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.id("main-tray")
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
proxmox_desktop::run()
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Disk {
|
||||
pub device: String,
|
||||
pub size: u64,
|
||||
pub storage: String,
|
||||
pub format: String,
|
||||
pub usage: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddDiskConfig {
|
||||
pub storage: String,
|
||||
pub size: u64,
|
||||
pub bus_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
pub node: String,
|
||||
pub status: String,
|
||||
pub cpu: f64,
|
||||
pub maxcpu: u32,
|
||||
pub mem: u64,
|
||||
pub maxmem: u64,
|
||||
pub disk: u64,
|
||||
pub maxdisk: u64,
|
||||
pub uptime: u64,
|
||||
pub level: String,
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VM {
|
||||
pub vmid: u32,
|
||||
pub name: Option<String>,
|
||||
pub status: String,
|
||||
pub r#type: String,
|
||||
pub node: String,
|
||||
pub cpu: f64,
|
||||
pub cpus: u32,
|
||||
pub mem: u64,
|
||||
pub maxmem: u64,
|
||||
pub disk: u64,
|
||||
pub maxdisk: u64,
|
||||
pub uptime: u64,
|
||||
pub netin: u64,
|
||||
pub netout: u64,
|
||||
pub diskread: u64,
|
||||
pub diskwrite: u64,
|
||||
pub pid: Option<u32>,
|
||||
pub template: Option<u32>,
|
||||
pub lock: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Storage {
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
pub active: u32,
|
||||
pub enabled: u32,
|
||||
pub shared: u32,
|
||||
pub used: u64,
|
||||
pub total: u64,
|
||||
pub avail: u64,
|
||||
pub node: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
pub upid: String,
|
||||
pub node: String,
|
||||
pub pid: u32,
|
||||
pub pstart: u64,
|
||||
pub starttime: u64,
|
||||
pub endtime: Option<u64>,
|
||||
pub r#type: String,
|
||||
pub id: String,
|
||||
pub user: String,
|
||||
pub status: Option<String>,
|
||||
pub exitstatus: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterStatus {
|
||||
pub r#type: String,
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub nodes: Option<Vec<ClusterNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterNode {
|
||||
pub name: String,
|
||||
pub nodeid: u32,
|
||||
pub online: u32,
|
||||
pub local: Option<u32>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub snaptime: u64,
|
||||
pub vmstate: u32,
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateSnapshotConfig {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub vmstate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub macaddr: String,
|
||||
pub bridge: Option<String>,
|
||||
pub tag: Option<u32>,
|
||||
pub firewall: Option<u32>,
|
||||
pub link_down: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddNICConfig {
|
||||
pub bridge: String,
|
||||
pub model: String,
|
||||
pub macaddr: Option<String>,
|
||||
pub tag: Option<u32>,
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditNICConfig {
|
||||
pub bridge: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub tag: Option<u32>,
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Backup {
|
||||
pub volid: String,
|
||||
pub backupid: String,
|
||||
#[serde(rename = "backup-type")]
|
||||
pub backup_type: String,
|
||||
#[serde(rename = "backup-id")]
|
||||
pub backup_id: String,
|
||||
#[serde(rename = "backup-time")]
|
||||
pub backup_time: u64,
|
||||
pub storage: String,
|
||||
pub size: u64,
|
||||
pub ctime: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
pub store: String,
|
||||
pub schedule: String,
|
||||
pub all: u32,
|
||||
pub enabled: u32,
|
||||
pub node: Option<String>,
|
||||
pub vmid: Option<String>,
|
||||
pub compress: Option<String>,
|
||||
pub mode: Option<String>,
|
||||
pub quiet: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BackupJobConfig {
|
||||
pub id: Option<String>,
|
||||
pub storage: String,
|
||||
pub schedule: String,
|
||||
pub mode: String,
|
||||
pub compression: String,
|
||||
pub all: bool,
|
||||
pub vmid: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub node: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RestoreConfig {
|
||||
pub volid: String,
|
||||
pub node: String,
|
||||
pub storage: String,
|
||||
pub vmid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageContent {
|
||||
pub content: String,
|
||||
pub ctime: u64,
|
||||
pub format: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub subtype: Option<String>,
|
||||
pub volid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageDetail {
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
pub active: u32,
|
||||
pub enabled: u32,
|
||||
pub shared: u32,
|
||||
pub used: u64,
|
||||
pub total: u64,
|
||||
pub avail: u64,
|
||||
pub node: String,
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::connect_async;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskUpdate {
|
||||
pub connection_id: String,
|
||||
pub upid: String,
|
||||
pub node: String,
|
||||
pub task_type: String,
|
||||
pub status: Option<String>,
|
||||
pub exitstatus: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStatusChange {
|
||||
pub connection_id: String,
|
||||
pub node: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VMStatusChange {
|
||||
pub connection_id: String,
|
||||
pub node: String,
|
||||
pub vmid: u32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Manages WebSocket connections per connection ID.
|
||||
///
|
||||
/// Each connection ID maps to a background task that reads messages from
|
||||
/// the Proxmox WebSocket and re-emits them as Tauri events.
|
||||
pub struct WebSocketManager {
|
||||
connections: HashMap<String, mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl WebSocketManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a Proxmox WebSocket URL for the given connection ID.
|
||||
///
|
||||
/// Messages are forwarded as Tauri events via `app_handle`. If a connection
|
||||
/// already exists for this ID, it is disconnected first.
|
||||
pub async fn connect(
|
||||
&mut self,
|
||||
connection_id: String,
|
||||
url: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
// Disconnect any existing connection for this ID
|
||||
self.disconnect(&connection_id).await;
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
let cid = connection_id.clone();
|
||||
let ws_url = url.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
const MAX_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
result = connect_and_run(&cid, &ws_url, &app_handle) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
// Normal close or stream ended – attempt reconnect
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[ws] connection error for {}: {e}", cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect back-off
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
_ = sleep(reconnect_delay) => {}
|
||||
}
|
||||
reconnect_delay = (reconnect_delay * 2).min(MAX_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
self.connections.insert(connection_id, shutdown_tx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect the WebSocket for the given connection ID.
|
||||
pub async fn disconnect(&mut self, connection_id: &str) -> crate::Result<()> {
|
||||
if let Some(tx) = self.connections.remove(connection_id) {
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a WebSocket connection is active.
|
||||
pub fn is_connected(&self, connection_id: &str) -> bool {
|
||||
self.connections.contains_key(connection_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the Proxmox WebSocket and relay messages as Tauri events.
|
||||
async fn connect_and_run(
|
||||
connection_id: &str,
|
||||
url: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
|
||||
let cid = connection_id.to_string();
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
while let Some(msg_result) = read.next().await {
|
||||
match msg_result {
|
||||
Ok(Message::Text(text)) => {
|
||||
handle_ws_message(&cid, &text, app_handle);
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("[ws] read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt clean close
|
||||
let _ = write.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a Proxmox WebSocket message and emit the appropriate Tauri event.
|
||||
///
|
||||
/// Proxmox sends JSON messages in the format:
|
||||
/// ```json
|
||||
/// { "type": "task", "data": { ... } }
|
||||
/// ```
|
||||
/// or various status update formats. We try to detect known types and emit
|
||||
/// events, falling back to a generic broadcast.
|
||||
fn handle_ws_message(connection_id: &str, text: &str, app_handle: &tauri::AppHandle) {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Try to detect task-related messages
|
||||
if let Some(msg_type) = value.get("type").and_then(|v| v.as_str()) {
|
||||
match msg_type {
|
||||
"task" => {
|
||||
if let Some(data) = value.get("data") {
|
||||
let update = TaskUpdate {
|
||||
connection_id: connection_id.to_string(),
|
||||
upid: data
|
||||
.get("upid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
node: data
|
||||
.get("node")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
task_type: data
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
status: data
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
exitstatus: data
|
||||
.get("exitstatus")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
};
|
||||
let _ = app_handle.emit("task-update", update);
|
||||
}
|
||||
}
|
||||
"node" => {
|
||||
if let Some(data) = value.get("data") {
|
||||
let change = NodeStatusChange {
|
||||
connection_id: connection_id.to_string(),
|
||||
node: data
|
||||
.get("node")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
status: data
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("node-status-change", change);
|
||||
}
|
||||
}
|
||||
"vm" => {
|
||||
if let Some(data) = value.get("data") {
|
||||
let change = VMStatusChange {
|
||||
connection_id: connection_id.to_string(),
|
||||
node: data
|
||||
.get("node")
|
||||
.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,
|
||||
status: data
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("vm-status-change", change);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown message type – emit generic event with raw data
|
||||
let _ = app_handle.emit(
|
||||
"ws-raw",
|
||||
serde_json::json!({
|
||||
"connection_id": connection_id,
|
||||
"data": value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"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":{}}
|
||||
@@ -0,0 +1,3 @@
|
||||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"$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"}
|
||||
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"$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"}
|
||||
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"$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"}
|
||||
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"$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.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
4609aa34aaf3f167
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"rustc":12019306335353385202,"features":"[]","declared_features":"[]","target":14045917370260632744,"profile":2225463790103693989,"path":11797909581934051219,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/unicode-ident-8443eb632a3fbe4c/dep-lib-unicode_ident","checksum":false}}],"rustflags":[],"config":9396254390672932401,"compile_kind":0}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+5
@@ -0,0 +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/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.
+5
@@ -0,0 +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/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.
+5
@@ -0,0 +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/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.
+5
@@ -0,0 +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/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:
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +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/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/.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,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxmoxDesktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.proxmoxdesktop.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"title": "ProxmoxDesktop",
|
||||
"windows": [
|
||||
{
|
||||
"title": "ProxmoxDesktop",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/* App.css - intentionally empty, using Tailwind CSS */
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
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 { TaskList } from '@/components/tasks/TaskList'
|
||||
import { BackupList } from '@/components/backups/BackupList'
|
||||
import { CommandPalette } from '@/components/command/CommandPalette'
|
||||
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 { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type View =
|
||||
| { type: 'dashboard' }
|
||||
| { type: 'vms' }
|
||||
| { type: 'vm-detail'; vm: ProxmoxVM }
|
||||
| { type: 'tasks' }
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string }
|
||||
| { type: 'settings' }
|
||||
|
||||
function AppContent() {
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false)
|
||||
const [view, setView] = useState<View>({ type: 'dashboard' })
|
||||
const commandPaletteOpen = useUIStore((s) => s.commandPaletteOpen)
|
||||
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen)
|
||||
|
||||
// WebSocket integration – connects when a connection is active
|
||||
useWebSocket(activeConnectionId)
|
||||
|
||||
// Global keyboard shortcut: Cmd/Ctrl+K to open command palette
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setCommandPaletteOpen(!commandPaletteOpen)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
}, [commandPaletteOpen, setCommandPaletteOpen])
|
||||
|
||||
const handleNavigate = (newView: View) => {
|
||||
setView(newView)
|
||||
}
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (view.type === 'settings') {
|
||||
return <SettingsPage />
|
||||
}
|
||||
|
||||
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">Welcome to ProxmoxDesktop</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Add a Proxmox server to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConnectionDialogOpen(true)}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Add Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (view.type) {
|
||||
case 'dashboard':
|
||||
return <Dashboard connectionId={activeConnectionId} />
|
||||
case 'vms':
|
||||
return (
|
||||
<VMList
|
||||
connectionId={activeConnectionId}
|
||||
onVMClick={(vm) => handleNavigate({ type: 'vm-detail', vm })}
|
||||
/>
|
||||
)
|
||||
case 'vm-detail':
|
||||
return (
|
||||
<VMDetail
|
||||
vm={view.vm}
|
||||
connectionId={activeConnectionId}
|
||||
onBack={() => handleNavigate({ type: 'vms' })}
|
||||
/>
|
||||
)
|
||||
case 'tasks':
|
||||
return <TaskList connectionId={activeConnectionId} />
|
||||
case 'backups':
|
||||
return <BackupList connectionId={activeConnectionId} />
|
||||
case 'storage':
|
||||
return (
|
||||
<StorageOverview
|
||||
connectionId={activeConnectionId}
|
||||
onStorageClick={(storage) =>
|
||||
handleNavigate({ type: 'storage-detail', storage })
|
||||
}
|
||||
/>
|
||||
)
|
||||
case 'storage-detail':
|
||||
return (
|
||||
<StorageDetail
|
||||
connectionId={activeConnectionId}
|
||||
storage={view.storage}
|
||||
onBack={() => handleNavigate({ type: 'storage' })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
onAddConnection={() => setConnectionDialogOpen(true)}
|
||||
activeView={view.type}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{renderMainContent()}
|
||||
</main>
|
||||
<ConnectionDialog
|
||||
open={connectionDialogOpen}
|
||||
onOpenChange={setConnectionDialogOpen}
|
||||
/>
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onNavigate={handleNavigate}
|
||||
onAddConnection={() => setConnectionDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<AppContent />
|
||||
</ErrorBoundary>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,57 @@
|
||||
import { Component } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
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>
|
||||
<Button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
variant="outline"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
Play,
|
||||
Edit,
|
||||
Trash,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
HardDrive,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useBackupJobs,
|
||||
useBackups,
|
||||
useDeleteBackupJob,
|
||||
useRunBackup,
|
||||
useDeleteBackup,
|
||||
useStorage,
|
||||
} from '@/hooks/useProxmox'
|
||||
import { CreateBackupJobDialog } from './dialogs/CreateBackupJobDialog'
|
||||
import { EditBackupJobDialog } from './dialogs/EditBackupJobDialog'
|
||||
import { RestoreBackupDialog } from './dialogs/RestoreBackupDialog'
|
||||
import type { ProxmoxBackupJob, ProxmoxBackup } from '@/types/proxmox'
|
||||
|
||||
interface BackupListProps {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const date = new Date(seconds * 1000)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export function BackupList({ connectionId }: BackupListProps) {
|
||||
const { data: backupJobs, isLoading: jobsLoading, error: jobsError } = useBackupJobs(connectionId)
|
||||
const { data: backups, isLoading: backupsLoading, error: backupsError } = useBackups(connectionId)
|
||||
const { data: storage } = useStorage(connectionId)
|
||||
|
||||
const deleteBackupJob = useDeleteBackupJob()
|
||||
const runBackup = useRunBackup()
|
||||
const deleteBackup = useDeleteBackup()
|
||||
|
||||
const [storageFilter, setStorageFilter] = useState<string>('all')
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialog, setEditDialog] = useState<ProxmoxBackupJob | null>(null)
|
||||
const [restoreDialog, setRestoreDialog] = useState<ProxmoxBackup | null>(null)
|
||||
const [confirmDeleteJob, setConfirmDeleteJob] = useState<string | null>(null)
|
||||
const [confirmDeleteBackup, setConfirmDeleteBackup] = useState<string | null>(null)
|
||||
|
||||
const storageOptions = useMemo(() => {
|
||||
if (!storage) return []
|
||||
return [...new Set(storage.map((s) => s.storage))].sort()
|
||||
}, [storage])
|
||||
|
||||
const nodeOptions = useMemo(() => {
|
||||
if (!storage) return []
|
||||
return [...new Set(storage.map((s) => s.node))].sort()
|
||||
}, [storage])
|
||||
|
||||
const filteredBackups = useMemo(() => {
|
||||
if (!backups) return []
|
||||
if (storageFilter === 'all') return backups
|
||||
return backups.filter((b) => b.storage === storageFilter)
|
||||
}, [backups, storageFilter])
|
||||
|
||||
if (jobsLoading || backupsLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading backups...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (jobsError || backupsError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load backup data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-6 w-6" />
|
||||
<h2 className="text-2xl font-semibold">Backups</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Manage backup jobs and existing backups
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Storage Filter */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={storageFilter}
|
||||
onChange={(e) => setStorageFilter(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Storage</option>
|
||||
{storageOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Backup Jobs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Backup Jobs</h3>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Job
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<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'}
|
||||
</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>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Run now"
|
||||
onClick={() =>
|
||||
runBackup.mutate({
|
||||
config: {
|
||||
id: job.id,
|
||||
storage: job.store,
|
||||
schedule: job.schedule,
|
||||
mode: (job.mode as 'snapshot' | 'stop' | 'suspend') || 'snapshot',
|
||||
compression: (job.compress as 'zstd' | 'lz4' | 'gzip' | 'none') || 'zstd',
|
||||
all: job.all === 1,
|
||||
vmid: job.vmid,
|
||||
enabled: job.enabled === 1,
|
||||
node: job.node,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Edit"
|
||||
onClick={() => setEditDialog(job)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Existing Backups Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">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>
|
||||
) : (
|
||||
<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>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Restore"
|
||||
onClick={() => setRestoreDialog(backup)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateBackupJobDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
storageOptions={storageOptions}
|
||||
/>
|
||||
|
||||
{editDialog && (
|
||||
<EditBackupJobDialog
|
||||
open={!!editDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditDialog(null)
|
||||
}}
|
||||
job={editDialog}
|
||||
storageOptions={storageOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{restoreDialog && (
|
||||
<RestoreBackupDialog
|
||||
open={!!restoreDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRestoreDialog(null)
|
||||
}}
|
||||
backup={restoreDialog}
|
||||
nodeOptions={nodeOptions}
|
||||
storageOptions={storageOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useCreateBackupJob } from '@/hooks/useProxmox'
|
||||
import type { BackupJobConfig } from '@/types/proxmox'
|
||||
|
||||
interface CreateBackupJobDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storageOptions: string[]
|
||||
}
|
||||
|
||||
export function CreateBackupJobDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
storageOptions,
|
||||
}: CreateBackupJobDialogProps) {
|
||||
const [storage, setStorage] = useState(storageOptions[0] ?? '')
|
||||
const [schedule, setSchedule] = useState('0 2 * * *')
|
||||
const [mode, setMode] = useState<BackupJobConfig['mode']>('snapshot')
|
||||
const [compression, setCompression] = useState<BackupJobConfig['compression']>('zstd')
|
||||
const [all, setAll] = useState(true)
|
||||
const [vmid, setVmid] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
const createBackupJob = useCreateBackupJob()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const config: BackupJobConfig = {
|
||||
storage,
|
||||
schedule,
|
||||
mode,
|
||||
compression,
|
||||
all,
|
||||
vmid: all ? undefined : vmid,
|
||||
enabled,
|
||||
}
|
||||
createBackupJob.mutate(
|
||||
{ config },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setStorage(storageOptions[0] ?? '')
|
||||
setSchedule('0 2 * * *')
|
||||
setMode('snapshot')
|
||||
setCompression('zstd')
|
||||
setAll(true)
|
||||
setVmid('')
|
||||
setEnabled(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Backup Job</DialogTitle>
|
||||
<DialogDescription>
|
||||
Schedule a recurring backup job for your VMs and containers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storage">Storage</Label>
|
||||
<select
|
||||
id="storage"
|
||||
value={storage}
|
||||
onChange={(e) => setStorage(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
required
|
||||
>
|
||||
{storageOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule">Schedule (cron)</Label>
|
||||
<Input
|
||||
id="schedule"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
placeholder="0 2 * * *"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Example: "0 2 * * *" = daily at 2:00 AM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mode">Backup Mode</Label>
|
||||
<select
|
||||
id="mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as BackupJobConfig['mode'])}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="snapshot">Snapshot (online, no downtime)</option>
|
||||
<option value="stop">Stop (shut down, backup, restart)</option>
|
||||
<option value="suspend">Suspend (pause, backup, resume)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression">Compression</Label>
|
||||
<select
|
||||
id="compression"
|
||||
value={compression}
|
||||
onChange={(e) => setCompression(e.target.value as BackupJobConfig['compression'])}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="zstd">ZSTD (recommended)</option>
|
||||
<option value="lz4">LZ4</option>
|
||||
<option value="gzip">GZIP</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="all"
|
||||
checked={all}
|
||||
onChange={(e) => setAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="all" className="text-sm font-normal cursor-pointer">
|
||||
Backup all VMs and containers
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!all && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vmid">VMIDs (comma-separated)</Label>
|
||||
<Input
|
||||
id="vmid"
|
||||
value={vmid}
|
||||
onChange={(e) => setVmid(e.target.value)}
|
||||
placeholder="100, 101, 102"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="enabled" className="text-sm font-normal cursor-pointer">
|
||||
Enabled
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{createBackupJob.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to create backup job. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createBackupJob.isPending || !storage || !schedule.trim()}
|
||||
>
|
||||
{createBackupJob.isPending ? 'Creating...' : 'Create Job'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useUpdateBackupJob } from '@/hooks/useProxmox'
|
||||
import type { BackupJobConfig, ProxmoxBackupJob } from '@/types/proxmox'
|
||||
|
||||
interface EditBackupJobDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
job: ProxmoxBackupJob
|
||||
storageOptions: string[]
|
||||
}
|
||||
|
||||
export function EditBackupJobDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
job,
|
||||
storageOptions,
|
||||
}: EditBackupJobDialogProps) {
|
||||
const [storage, setStorage] = useState(job.store)
|
||||
const [schedule, setSchedule] = useState(job.schedule)
|
||||
const [mode, setMode] = useState<BackupJobConfig['mode']>(
|
||||
(job.mode as BackupJobConfig['mode']) || 'snapshot',
|
||||
)
|
||||
const [compression, setCompression] = useState<BackupJobConfig['compression']>(
|
||||
(job.compress as BackupJobConfig['compression']) || 'zstd',
|
||||
)
|
||||
const [all, setAll] = useState(job.all === 1)
|
||||
const [vmid, setVmid] = useState(job.vmid ?? '')
|
||||
const [enabled, setEnabled] = useState(job.enabled === 1)
|
||||
|
||||
const updateBackupJob = useUpdateBackupJob()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStorage(job.store)
|
||||
setSchedule(job.schedule)
|
||||
setMode((job.mode as BackupJobConfig['mode']) || 'snapshot')
|
||||
setCompression((job.compress as BackupJobConfig['compression']) || 'zstd')
|
||||
setAll(job.all === 1)
|
||||
setVmid(job.vmid ?? '')
|
||||
setEnabled(job.enabled === 1)
|
||||
}
|
||||
}, [open, job])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const config: BackupJobConfig = {
|
||||
storage,
|
||||
schedule,
|
||||
mode,
|
||||
compression,
|
||||
all,
|
||||
vmid: all ? undefined : vmid,
|
||||
enabled,
|
||||
}
|
||||
updateBackupJob.mutate(
|
||||
{ id: job.id, config },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Backup Job</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modify the backup job configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storage">Storage</Label>
|
||||
<select
|
||||
id="storage"
|
||||
value={storage}
|
||||
onChange={(e) => setStorage(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
required
|
||||
>
|
||||
{storageOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule">Schedule (cron)</Label>
|
||||
<Input
|
||||
id="schedule"
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
placeholder="0 2 * * *"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Example: "0 2 * * *" = daily at 2:00 AM
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mode">Backup Mode</Label>
|
||||
<select
|
||||
id="mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as BackupJobConfig['mode'])}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="snapshot">Snapshot (online, no downtime)</option>
|
||||
<option value="stop">Stop (shut down, backup, restart)</option>
|
||||
<option value="suspend">Suspend (pause, backup, resume)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compression">Compression</Label>
|
||||
<select
|
||||
id="compression"
|
||||
value={compression}
|
||||
onChange={(e) => setCompression(e.target.value as BackupJobConfig['compression'])}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="zstd">ZSTD (recommended)</option>
|
||||
<option value="lz4">LZ4</option>
|
||||
<option value="gzip">GZIP</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="all"
|
||||
checked={all}
|
||||
onChange={(e) => setAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="all" className="text-sm font-normal cursor-pointer">
|
||||
Backup all VMs and containers
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!all && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vmid">VMIDs (comma-separated)</Label>
|
||||
<Input
|
||||
id="vmid"
|
||||
value={vmid}
|
||||
onChange={(e) => setVmid(e.target.value)}
|
||||
placeholder="100, 101, 102"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="enabled" className="text-sm font-normal cursor-pointer">
|
||||
Enabled
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{updateBackupJob.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to update backup job. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateBackupJob.isPending || !storage || !schedule.trim()}
|
||||
>
|
||||
{updateBackupJob.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useRestoreBackup } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxBackup } from '@/types/proxmox'
|
||||
|
||||
interface RestoreBackupDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
backup: ProxmoxBackup | null
|
||||
nodeOptions: string[]
|
||||
storageOptions: string[]
|
||||
}
|
||||
|
||||
export function RestoreBackupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
backup,
|
||||
nodeOptions,
|
||||
storageOptions,
|
||||
}: RestoreBackupDialogProps) {
|
||||
const [node, setNode] = useState(nodeOptions[0] ?? '')
|
||||
const [targetStorage, setTargetStorage] = useState(storageOptions[0] ?? '')
|
||||
const [vmid, setVmid] = useState('')
|
||||
|
||||
const restoreBackup = useRestoreBackup()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!backup) return
|
||||
restoreBackup.mutate(
|
||||
{
|
||||
volid: backup.volid,
|
||||
config: {
|
||||
volid: backup.volid,
|
||||
node,
|
||||
storage: targetStorage,
|
||||
vmid: vmid ? parseInt(vmid, 10) : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
setVmid('')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restore Backup</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restore backup {backup?.volid ?? ''}. This will create a new VM from the backup.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="node">Target Node</Label>
|
||||
<select
|
||||
id="node"
|
||||
value={node}
|
||||
onChange={(e) => setNode(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
required
|
||||
>
|
||||
{nodeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetStorage">Target Storage</Label>
|
||||
<select
|
||||
id="targetStorage"
|
||||
value={targetStorage}
|
||||
onChange={(e) => setTargetStorage(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
required
|
||||
>
|
||||
{storageOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vmid">New VMID (optional)</Label>
|
||||
<Input
|
||||
id="vmid"
|
||||
type="number"
|
||||
value={vmid}
|
||||
onChange={(e) => setVmid(e.target.value)}
|
||||
placeholder="Auto-assign if empty"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to auto-assign the next available VMID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Warning: Restoring will overwrite any existing VM with the same VMID.
|
||||
</p>
|
||||
|
||||
{restoreBackup.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to restore backup. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={restoreBackup.isPending || !node || !targetStorage}>
|
||||
{restoreBackup.isPending ? 'Restoring...' : 'Restore Backup'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import {
|
||||
useStartVM,
|
||||
useStopVM,
|
||||
useShutdownVM,
|
||||
useRebootVM,
|
||||
} from '@/hooks/useProxmox'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Server,
|
||||
Play,
|
||||
Square,
|
||||
Power,
|
||||
RotateCw,
|
||||
LayoutDashboard,
|
||||
Box,
|
||||
ListTodo,
|
||||
Shield,
|
||||
HardDrive,
|
||||
Plus,
|
||||
X,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type View =
|
||||
| { type: 'dashboard' }
|
||||
| { type: 'vms' }
|
||||
| { type: 'vm-detail'; vm: ProxmoxVM }
|
||||
| { type: 'tasks' }
|
||||
| { type: 'backups' }
|
||||
| { type: 'storage' }
|
||||
| { type: 'storage-detail'; storage: string }
|
||||
|
||||
type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections'
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
category: CommandCategory
|
||||
shortcut?: string
|
||||
keywords: string[]
|
||||
onExecute: () => void
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onNavigate: (view: View) => void
|
||||
onAddConnection: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent commands – localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RECENT_STORAGE_KEY = 'proxmox-command-palette-recent'
|
||||
const MAX_RECENT = 10
|
||||
|
||||
function loadRecent(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecent(ids: string[]) {
|
||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
function pushRecent(id: string) {
|
||||
const current = loadRecent().filter((r) => r !== id)
|
||||
current.unshift(id)
|
||||
saveRecent(current.slice(0, MAX_RECENT))
|
||||
}
|
||||
|
||||
function clearRecent() {
|
||||
localStorage.removeItem(RECENT_STORAGE_KEY)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fuzzy match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const lowerText = text.toLowerCase()
|
||||
|
||||
// Substring match
|
||||
if (lowerText.includes(lowerQuery)) return true
|
||||
|
||||
// Character-by-character fuzzy
|
||||
let qi = 0
|
||||
for (let ti = 0; ti < lowerText.length && qi < lowerQuery.length; ti++) {
|
||||
if (lowerText[ti] === lowerQuery[qi]) qi++
|
||||
}
|
||||
return qi === lowerQuery.length
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlighted text component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>
|
||||
|
||||
const lowerText = text.toLowerCase()
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const idx = lowerText.indexOf(lowerQuery)
|
||||
|
||||
if (idx !== -1) {
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-accent-foreground/20 text-foreground rounded-sm">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Fuzzy: highlight individual matched characters
|
||||
const chars = text.split('')
|
||||
let qi = 0
|
||||
return (
|
||||
<>
|
||||
{chars.map((char, i) => {
|
||||
if (qi < lowerQuery.length && char.toLowerCase() === lowerQuery[qi]) {
|
||||
qi++
|
||||
return (
|
||||
<mark key={i} className="bg-accent-foreground/20 text-foreground rounded-sm">
|
||||
{char}
|
||||
</mark>
|
||||
)
|
||||
}
|
||||
return char
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function categoryHeading(category: CommandCategory): string {
|
||||
switch (category) {
|
||||
case 'recent':
|
||||
return 'Recent'
|
||||
case 'vms':
|
||||
return 'VMs & Containers'
|
||||
case 'actions':
|
||||
return 'Actions'
|
||||
case 'navigation':
|
||||
return 'Navigation'
|
||||
case 'connections':
|
||||
return 'Connections'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandPalette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onNavigate,
|
||||
onAddConnection,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [recentIds, setRecentIds] = useState<string[]>(loadRecent)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Data from stores
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
|
||||
|
||||
const { data: vms = [] } = useVMs(activeConnectionId)
|
||||
|
||||
// VM mutation hooks
|
||||
const startVM = useStartVM()
|
||||
const stopVM = useStopVM()
|
||||
const shutdownVM = useShutdownVM()
|
||||
const rebootVM = useRebootVM()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build all command items
|
||||
// -----------------------------------------------------------------------
|
||||
const buildItems = useCallback((): CommandItem[] => {
|
||||
const items: CommandItem[] = []
|
||||
|
||||
// -- Navigation --
|
||||
items.push(
|
||||
{
|
||||
id: 'nav-dashboard',
|
||||
label: 'Go to Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘1',
|
||||
keywords: ['dashboard', 'home', 'overview'],
|
||||
onExecute: () => onNavigate({ type: 'dashboard' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-vms',
|
||||
label: 'Go to VMs',
|
||||
icon: Box,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘2',
|
||||
keywords: ['vm', 'vms', 'virtual machines', 'containers'],
|
||||
onExecute: () => onNavigate({ type: 'vms' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-tasks',
|
||||
label: 'Go to Tasks',
|
||||
icon: ListTodo,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘3',
|
||||
keywords: ['tasks', 'jobs', 'queue'],
|
||||
onExecute: () => onNavigate({ type: 'tasks' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-backups',
|
||||
label: 'Go to Backups',
|
||||
icon: Shield,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘4',
|
||||
keywords: ['backups', 'restore', 'backup'],
|
||||
onExecute: () => onNavigate({ type: 'backups' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-storage',
|
||||
label: 'Go to Storage',
|
||||
icon: HardDrive,
|
||||
category: 'navigation',
|
||||
shortcut: '⌘5',
|
||||
keywords: ['storage', 'disks', 'volumes'],
|
||||
onExecute: () => onNavigate({ type: 'storage' }),
|
||||
},
|
||||
{
|
||||
id: 'nav-add-connection',
|
||||
label: 'Add Connection',
|
||||
icon: Plus,
|
||||
category: 'navigation',
|
||||
keywords: ['add', 'connection', 'server', 'proxmox', 'new'],
|
||||
onExecute: () => onAddConnection(),
|
||||
},
|
||||
)
|
||||
|
||||
// -- VMs --
|
||||
for (const vm of vms) {
|
||||
items.push({
|
||||
id: `vm-detail-${vm.vmid}`,
|
||||
label: vm.name,
|
||||
description: `${vm.type.toUpperCase()} · VMID ${vm.vmid} · ${vm.node} · ${vm.status}`,
|
||||
icon: Server,
|
||||
category: 'vms',
|
||||
keywords: [vm.name, String(vm.vmid), vm.node, vm.type, vm.status],
|
||||
onExecute: () => onNavigate({ type: 'vm-detail', vm }),
|
||||
})
|
||||
}
|
||||
|
||||
// -- VM Actions --
|
||||
for (const vm of vms) {
|
||||
if (vm.status === 'running') {
|
||||
items.push(
|
||||
{
|
||||
id: `action-stop-${vm.vmid}`,
|
||||
label: `Stop ${vm.name}`,
|
||||
description: `Force stop ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Square,
|
||||
category: 'actions',
|
||||
keywords: ['stop', 'halt', 'power off', vm.name, String(vm.vmid)],
|
||||
onExecute: () => stopVM.mutate({ node: vm.node, vmid: vm.vmid }),
|
||||
},
|
||||
{
|
||||
id: `action-shutdown-${vm.vmid}`,
|
||||
label: `Shutdown ${vm.name}`,
|
||||
description: `Gracefully shutdown ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Power,
|
||||
category: 'actions',
|
||||
keywords: ['shutdown', 'graceful', 'power', vm.name, String(vm.vmid)],
|
||||
onExecute: () => shutdownVM.mutate({ node: vm.node, vmid: vm.vmid }),
|
||||
},
|
||||
{
|
||||
id: `action-reboot-${vm.vmid}`,
|
||||
label: `Reboot ${vm.name}`,
|
||||
description: `Reboot ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: RotateCw,
|
||||
category: 'actions',
|
||||
keywords: ['reboot', 'restart', vm.name, String(vm.vmid)],
|
||||
onExecute: () => rebootVM.mutate({ node: vm.node, vmid: vm.vmid }),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (vm.status === 'stopped' || vm.status === 'paused') {
|
||||
items.push({
|
||||
id: `action-start-${vm.vmid}`,
|
||||
label: `Start ${vm.name}`,
|
||||
description: `Start ${vm.type.toUpperCase()} VMID ${vm.vmid}`,
|
||||
icon: Play,
|
||||
category: 'actions',
|
||||
keywords: ['start', 'boot', 'power on', vm.name, String(vm.vmid)],
|
||||
onExecute: () => startVM.mutate({ node: vm.node, vmid: vm.vmid }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -- Connections --
|
||||
for (const conn of connections) {
|
||||
items.push({
|
||||
id: `conn-${conn.id}`,
|
||||
label: conn.name,
|
||||
description: `Switch to ${conn.name} (${conn.status})`,
|
||||
icon: Server,
|
||||
category: 'connections',
|
||||
keywords: [conn.name, conn.status, 'switch', 'connection'],
|
||||
onExecute: () => setActiveConnection(conn.id),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [
|
||||
vms,
|
||||
connections,
|
||||
onNavigate,
|
||||
onAddConnection,
|
||||
startVM,
|
||||
stopVM,
|
||||
shutdownVM,
|
||||
rebootVM,
|
||||
setActiveConnection,
|
||||
])
|
||||
|
||||
const allItems = useMemo(() => buildItems(), [buildItems])
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Filter & group
|
||||
// -----------------------------------------------------------------------
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = query.trim()
|
||||
|
||||
if (!q) {
|
||||
// Empty query: recent → navigation → everything else
|
||||
const recentItems = recentIds
|
||||
.map((id) => allItems.find((item) => item.id === id))
|
||||
.filter((item): item is CommandItem => item !== undefined)
|
||||
.map((item) => ({ ...item, category: 'recent' as const }))
|
||||
|
||||
const navItems = allItems.filter((item) => item.category === 'navigation')
|
||||
const otherItems = allItems.filter(
|
||||
(item) => item.category !== 'navigation' && item.category !== 'recent',
|
||||
)
|
||||
|
||||
return [...recentItems, ...navItems, ...otherItems]
|
||||
}
|
||||
|
||||
return allItems.filter((item) => {
|
||||
const haystack = [item.label, item.description ?? '', ...item.keywords].join(' ')
|
||||
return fuzzyMatch(q, haystack)
|
||||
})
|
||||
}, [query, allItems, recentIds])
|
||||
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: { heading: string; items: CommandItem[] }[] = []
|
||||
|
||||
if (query.trim()) {
|
||||
const buckets: Record<string, CommandItem[]> = {}
|
||||
for (const item of filteredItems) {
|
||||
const heading = item.category === 'recent' ? 'Recent' : categoryHeading(item.category)
|
||||
if (!buckets[heading]) buckets[heading] = []
|
||||
buckets[heading].push(item)
|
||||
}
|
||||
for (const [heading, items] of Object.entries(buckets)) {
|
||||
groups.push({ heading, items })
|
||||
}
|
||||
} else {
|
||||
let currentHeading = ''
|
||||
let currentItems: CommandItem[] = []
|
||||
for (const item of filteredItems) {
|
||||
const heading = item.category === 'recent' ? 'Recent' : categoryHeading(item.category)
|
||||
if (heading !== currentHeading) {
|
||||
if (currentItems.length > 0) groups.push({ heading: currentHeading, items: currentItems })
|
||||
currentHeading = heading
|
||||
currentItems = [item]
|
||||
} else {
|
||||
currentItems.push(item)
|
||||
}
|
||||
}
|
||||
if (currentItems.length > 0) groups.push({ heading: currentHeading, items: currentItems })
|
||||
}
|
||||
|
||||
return groups
|
||||
}, [filteredItems, query])
|
||||
|
||||
const flatItems = useMemo(() => groupedItems.flatMap((g) => g.items), [groupedItems])
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset on open
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
setRecentIds(loadRecent())
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset selection on query change
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [query])
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Keep selected item in view
|
||||
// -----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-cmd-idx="${selectedIndex}"]`)
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Execute item
|
||||
// -----------------------------------------------------------------------
|
||||
const executeItem = useCallback(
|
||||
(item: CommandItem) => {
|
||||
pushRecent(item.id)
|
||||
setRecentIds(loadRecent())
|
||||
item.onExecute()
|
||||
onOpenChange(false)
|
||||
},
|
||||
[onOpenChange],
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Keyboard handling
|
||||
// -----------------------------------------------------------------------
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, flatItems.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (flatItems[selectedIndex]) executeItem(flatItems[selectedIndex])
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onOpenChange(false)
|
||||
break
|
||||
}
|
||||
},
|
||||
[flatItems, selectedIndex, executeItem, onOpenChange],
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Flat index helper
|
||||
// -----------------------------------------------------------------------
|
||||
function flatIndex(groupIdx: number, itemIdx: number): number {
|
||||
let offset = 0
|
||||
for (let g = 0; g < groupIdx; g++) offset += groupedItems[g].items.length
|
||||
return offset + itemIdx
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 shadow-lg gap-0 max-w-xl"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Type a command or search..."
|
||||
className="flex h-11 w-full rounded-md bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="p-1 rounded-sm hover:bg-accent text-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<ScrollArea className="max-h-80">
|
||||
<div ref={listRef} className="p-1">
|
||||
{flatItems.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
groupedItems.map((group, gIdx) => (
|
||||
<div key={group.heading}>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground select-none">
|
||||
{group.heading}
|
||||
</div>
|
||||
{group.items.map((item, iIdx) => {
|
||||
const idx = flatIndex(gIdx, iIdx)
|
||||
const isActive = idx === selectedIndex
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-cmd-idx={idx}
|
||||
onClick={() => executeItem(item)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm outline-none transition-colors text-left',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate">
|
||||
<HighlightedText text={item.label} query={query} />
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer with keyboard hints */}
|
||||
<div className="flex items-center justify-between border-t px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">↑</kbd>
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">↓</kbd>
|
||||
navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">↵</kbd>
|
||||
select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="rounded border bg-muted px-1 font-mono">esc</kbd>
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
{recentIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearRecent()
|
||||
setRecentIds([])
|
||||
}}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear recent
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import type { ConnectionConfig } from '@/types/connection'
|
||||
|
||||
interface ConnectionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ConnectionDialog({ open, onOpenChange }: ConnectionDialogProps) {
|
||||
const addConnection = useConnectionStore((s) => s.addConnection)
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiToken, setApiToken] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
if (!url.startsWith('https://')) {
|
||||
throw new Error('URL must start with https://')
|
||||
}
|
||||
|
||||
// Create connection config
|
||||
const config: ConnectionConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name || 'New Connection',
|
||||
primary: {
|
||||
url: url.replace(/\/$/, ''), // Remove trailing slash
|
||||
token: apiToken,
|
||||
},
|
||||
fallbacks: [],
|
||||
trusted: false,
|
||||
status: 'disconnected',
|
||||
isCluster: false,
|
||||
}
|
||||
|
||||
// Add connection to store
|
||||
addConnection(config)
|
||||
|
||||
// Reset form and close dialog
|
||||
setName('')
|
||||
setUrl('')
|
||||
setApiToken('')
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add connection')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Proxmox Connection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect to a Proxmox VE server or cluster
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Connection Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Home Lab"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{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...' : 'Add Connection'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
interface TerminalConsoleProps {
|
||||
connectionId: string
|
||||
node: string
|
||||
vmid: number
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalConsoleProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
fitAddonRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
// Create terminal
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
selectionBackground: '#264f78',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
terminal.open(containerRef.current!)
|
||||
fitAddon.fit()
|
||||
|
||||
termRef.current = terminal
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
if (cancelled) {
|
||||
terminal.dispose()
|
||||
return
|
||||
}
|
||||
|
||||
// Get WebSocket URL for terminal proxy
|
||||
let wsUrl: string
|
||||
|
||||
try {
|
||||
const { isTauri, createTermProxy, getWebSocketURL } = await import('@/lib/tauri')
|
||||
|
||||
if (isTauri()) {
|
||||
const [proxyInfo, baseUrl] = await Promise.all([
|
||||
createTermProxy(connectionId, node, vmid),
|
||||
getWebSocketURL(connectionId, node),
|
||||
])
|
||||
|
||||
wsUrl = `${baseUrl}/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=${proxyInfo.port}&ticket=${encodeURIComponent(proxyInfo.ticket)}`
|
||||
} else {
|
||||
// Dev mode: construct a mock URL
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
|
||||
}
|
||||
} catch {
|
||||
// Fallback for dev mode
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/lxc/${vmid}/proxy?port=6100&ticket=mock-ticket`
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
// In dev mode, show a mock terminal since we can't connect to real WebSocket
|
||||
let isTauriMode = false
|
||||
try {
|
||||
const { isTauri } = await import('@/lib/tauri')
|
||||
isTauriMode = isTauri()
|
||||
} catch {
|
||||
// not in tauri
|
||||
}
|
||||
|
||||
if (!isTauriMode && !cancelled) {
|
||||
// Dev mode mock terminal
|
||||
terminal.writeln('\x1b[1;32m╔══════════════════════════════════════════╗\x1b[0m')
|
||||
terminal.writeln('\x1b[1;32m║ ProxmoxDesktop - Terminal Console ║\x1b[0m')
|
||||
terminal.writeln('\x1b[1;32m╚══════════════════════════════════════════╝\x1b[0m')
|
||||
terminal.writeln('')
|
||||
terminal.writeln(`\x1b[33mNode: ${node} | VMID: ${vmid} | Type: LXC\x1b[0m`)
|
||||
terminal.writeln('')
|
||||
terminal.writeln('\x1b[90m[Dev mode - WebSocket connection mocked]\x1b[0m')
|
||||
terminal.writeln('')
|
||||
|
||||
// Mock shell interaction
|
||||
const prompt = () => {
|
||||
terminal.write(`\x1b[1;34mroot@${node}\x1b[0m:\x1b[1;35m~\x1b[0m# `)
|
||||
}
|
||||
prompt()
|
||||
|
||||
let inputBuffer = ''
|
||||
terminal.onData((data: string) => {
|
||||
if (data === '\r') {
|
||||
terminal.writeln('')
|
||||
if (inputBuffer.trim()) {
|
||||
if (inputBuffer.trim() === 'clear') {
|
||||
terminal.clear()
|
||||
} else if (inputBuffer.trim() === 'help') {
|
||||
terminal.writeln('Available mock commands: clear, help, ls, whoami')
|
||||
} else if (inputBuffer.trim() === 'ls') {
|
||||
terminal.writeln('bin boot dev etc home lib lib64 media mnt opt')
|
||||
terminal.writeln('proc root run sbin srv sys tmp usr var')
|
||||
} else if (inputBuffer.trim() === 'whoami') {
|
||||
terminal.writeln('root')
|
||||
} else {
|
||||
terminal.writeln(`\x1b[31m${inputBuffer.trim()}: command not found\x1b[0m`)
|
||||
}
|
||||
}
|
||||
inputBuffer = ''
|
||||
prompt()
|
||||
} else if (data === '\x7f') {
|
||||
// Backspace
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
terminal.write('\b \b')
|
||||
}
|
||||
} else if (data >= ' ') {
|
||||
inputBuffer += data
|
||||
terminal.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!cancelled && fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit()
|
||||
} catch {
|
||||
// ignore resize errors
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current!)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
// Production: connect via WebSocket
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!cancelled) {
|
||||
terminal.writeln(`\x1b[32mConnected to LXC container ${vmid} on ${node}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!cancelled && typeof ev.data === 'string') {
|
||||
terminal.write(ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!cancelled) {
|
||||
onError?.('WebSocket connection error')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!cancelled) {
|
||||
terminal.writeln('\r\n\x1b[33mConnection closed\x1b[0m')
|
||||
}
|
||||
}
|
||||
|
||||
// Forward terminal input to WebSocket
|
||||
terminal.onData((data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle terminal resize
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
})
|
||||
|
||||
// Handle container resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!cancelled && fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit()
|
||||
} catch {
|
||||
// ignore resize errors
|
||||
}
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.current!)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
onError?.(err instanceof Error ? err.message : 'Failed to create terminal')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cleanupResize: (() => void) | undefined
|
||||
|
||||
const run = async () => {
|
||||
cleanupResize = await connect() ?? undefined
|
||||
}
|
||||
|
||||
run()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanupResize?.()
|
||||
cleanup()
|
||||
}
|
||||
}, [connectionId, node, vmid, onError, cleanup])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full bg-black overflow-hidden"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { TerminalConsoleProps }
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface VNCConsoleProps {
|
||||
connectionId: string
|
||||
node: string
|
||||
vmid: number
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<unknown>(null)
|
||||
const stateRef = useRef<ConnectionState>('connecting')
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (rfbRef.current) {
|
||||
const rfb = rfbRef.current as { disconnect: () => void }
|
||||
rfb.disconnect()
|
||||
rfbRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
stateRef.current = 'connecting'
|
||||
|
||||
// Get WebSocket URL for VNC
|
||||
let wsUrl: string
|
||||
|
||||
try {
|
||||
// Try real IPC first
|
||||
const { isTauri, createVNCProxy, getWebSocketURL } = await import('@/lib/tauri')
|
||||
|
||||
if (isTauri()) {
|
||||
const [proxyInfo, baseUrl] = await Promise.all([
|
||||
createVNCProxy(connectionId, node, vmid),
|
||||
getWebSocketURL(connectionId, node),
|
||||
])
|
||||
|
||||
wsUrl = `${baseUrl}/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=${proxyInfo.port}&vncticket=${encodeURIComponent(proxyInfo.ticket)}`
|
||||
} else {
|
||||
// Dev mode: construct a mock URL
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
|
||||
}
|
||||
} catch {
|
||||
// Fallback for dev mode
|
||||
wsUrl = `wss://localhost:8006/api2/json/nodes/${node}/qemu/${vmid}/vncwebsocket?port=6000&vncticket=mock-ticket`
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamically import noVNC RFB class
|
||||
const { default: RFB } = await import('@novnc/novnc/core/rfb.js')
|
||||
|
||||
if (cancelled || !containerRef.current) return
|
||||
|
||||
// Create RFB connection
|
||||
const rfb = new RFB(containerRef.current, wsUrl, {
|
||||
shared: true,
|
||||
wsProtocols: ['binary'],
|
||||
})
|
||||
|
||||
rfbRef.current = rfb
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (!cancelled) {
|
||||
stateRef.current = 'connected'
|
||||
}
|
||||
})
|
||||
|
||||
rfb.addEventListener('disconnect', (ev: unknown) => {
|
||||
if (!cancelled) {
|
||||
const detail = (ev as CustomEvent<{ reason?: string }>).detail
|
||||
stateRef.current = 'disconnected'
|
||||
if (detail?.reason) {
|
||||
onError?.(`VNC disconnected: ${detail.reason}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
if (!cancelled) {
|
||||
onError?.('VNC credentials required')
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
stateRef.current = 'error'
|
||||
onError?.(err instanceof Error ? err.message : 'Failed to connect VNC')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cleanup()
|
||||
}
|
||||
}, [connectionId, node, vmid, onError, cleanup])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full bg-[#404040] overflow-hidden"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { VNCConsoleProps }
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Activity, Clock } from 'lucide-react'
|
||||
import type { ProxmoxTask } from '@/types/proxmox'
|
||||
|
||||
interface ActivityFeedProps {
|
||||
tasks: ProxmoxTask[] | undefined
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
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'
|
||||
case 'unknown':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
default:
|
||||
if (!status || status === 'Running') {
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status?: string): string {
|
||||
if (!status || status === 'Running') return 'Running'
|
||||
return status
|
||||
}
|
||||
|
||||
function formatDuration(starttime: number, endtime?: number): string {
|
||||
const end = endtime ?? Math.floor(Date.now() / 1000)
|
||||
const durationSeconds = Math.max(0, end - starttime)
|
||||
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${seconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function getTaskTypeLabel(type: string): string {
|
||||
// Capitalize and format Proxmox task types
|
||||
return type
|
||||
.replace(/qm/gi, 'VM')
|
||||
.replace(/vz/gi, 'CT')
|
||||
.replace(/storage/gi, 'Storage')
|
||||
.replace(/backup/gi, 'Backup')
|
||||
.replace(/restore/gi, 'Restore')
|
||||
.replace(/snapshot/gi, 'Snapshot')
|
||||
.split(/[\s/]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Loading tasks...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const recentTasks = tasks?.slice(0, 15) ?? []
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentTasks.length > 0 ? (
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-2">
|
||||
{recentTasks.map((task) => (
|
||||
<div
|
||||
key={task.upid}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<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>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${getStatusBadgeClass(task.status)}`}
|
||||
>
|
||||
{getStatusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{task.node} · {task.user}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatDuration(task.starttime, task.endtime)}</span>
|
||||
</div>
|
||||
<span>{formatTime(task.starttime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No recent activity</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface NodeHealthGridProps {
|
||||
nodes: ProxmoxNode[] | undefined
|
||||
vms: ProxmoxVM[] | undefined
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getStatusColor(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'bg-green-500' : 'bg-red-500'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: 'online' | 'offline'): string {
|
||||
return status === 'online' ? 'Online' : 'Offline'
|
||||
}
|
||||
|
||||
/** Generate deterministic mock sparkline data from node stats */
|
||||
function generateSparklineData(baseValue: number, points: number = 12): { value: number }[] {
|
||||
const data: { value: number }[] = []
|
||||
for (let i = 0; i < points; i++) {
|
||||
// Deterministic pseudo-random variation around the base value
|
||||
const seed = Math.sin(i * 2.1 + baseValue * 0.01) * 0.3
|
||||
const variation = baseValue * seed
|
||||
data.push({ value: Math.max(0, Math.min(1, baseValue + variation)) })
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
|
||||
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
|
||||
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
|
||||
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
|
||||
|
||||
const cpuData = useMemo(() => generateSparklineData(cpuPercent), [cpuPercent])
|
||||
const memData = useMemo(() => generateSparklineData(memPercent), [memPercent])
|
||||
|
||||
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>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{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">
|
||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">CPU</span>
|
||||
</div>
|
||||
<div className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={cpuData}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
fill="hsl(var(--color-primary))"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<MemoryStick className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">RAM</span>
|
||||
</div>
|
||||
<div className="h-8">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={memData}>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
fill="hsl(var(--color-primary))"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
<span>{(cpuPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="h-3 w-3" />
|
||||
<span>{(memPercent * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<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" />
|
||||
<span>{formatUptime(node.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Box className="h-3 w-3" />
|
||||
<span>{vmCount} VMs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>{formatBytes(node.disk)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
|
||||
// Count VMs per node
|
||||
const vmCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
vms?.forEach((vm) => {
|
||||
counts[vm.node] = (counts[vm.node] ?? 0) + 1
|
||||
})
|
||||
return counts
|
||||
}, [vms])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Node Health</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No nodes available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Node Health</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.node} node={node} vmCount={vmCounts[node.node] ?? 0} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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'
|
||||
|
||||
interface QuickActionsProps {
|
||||
onRefresh: () => void
|
||||
isRefreshing: boolean
|
||||
}
|
||||
|
||||
export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle>
|
||||
</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"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span className="text-xs">Refresh</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-xs">Nodes</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
<span className="text-xs">VMs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="text-xs">Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<span className="text-xs">Tasks</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col items-center gap-1.5 h-auto py-3"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-xs">Backups</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
|
||||
interface ResourceGaugeProps {
|
||||
label: string
|
||||
used: number
|
||||
total: number
|
||||
icon: 'cpu' | 'memory' | 'disk'
|
||||
formatValue?: (value: number) => string
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
cpu: Cpu,
|
||||
memory: MemoryStick,
|
||||
disk: HardDrive,
|
||||
} as const
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatCores(cores: number): string {
|
||||
return `${cores.toFixed(1)} cores`
|
||||
}
|
||||
|
||||
function getGaugeColor(percent: number): string {
|
||||
if (percent >= 0.9) return 'stroke-destructive'
|
||||
if (percent >= 0.7) return 'stroke-yellow-500'
|
||||
return 'stroke-primary'
|
||||
}
|
||||
|
||||
function getTrackColor(): string {
|
||||
return 'stroke-muted'
|
||||
}
|
||||
|
||||
export function ResourceGauge({ label, used, total, icon, formatValue }: ResourceGaugeProps) {
|
||||
const percent = total > 0 ? used / total : 0
|
||||
const clampedPercent = Math.min(Math.max(percent, 0), 1)
|
||||
const displayPercent = (clampedPercent * 100).toFixed(1)
|
||||
|
||||
const Icon = iconMap[icon]
|
||||
|
||||
// SVG circle parameters
|
||||
const radius = 40
|
||||
const strokeWidth = 8
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference * (1 - clampedPercent)
|
||||
|
||||
const defaultFormat =
|
||||
icon === 'cpu'
|
||||
? formatCores
|
||||
: formatBytes
|
||||
|
||||
const formatter = formatValue ?? defaultFormat
|
||||
|
||||
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>
|
||||
<CardContent className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className={getTrackColor()}
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={getGaugeColor(clampedPercent)}
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
transformOrigin: '50% 50%',
|
||||
transition: 'stroke-dashoffset 0.6s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-lg font-bold">{displayPercent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
{formatter(used)} / {formatter(total)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Server, Cpu, HardDrive, MemoryStick } from 'lucide-react'
|
||||
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
|
||||
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
|
||||
import { ActivityFeed } from '@/components/dashboard/ActivityFeed'
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||
|
||||
interface DashboardProps {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export function Dashboard({ connectionId }: DashboardProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: nodes, isLoading: nodesLoading } = useNodes(connectionId)
|
||||
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
|
||||
const { data: tasks, isLoading: tasksLoading } = useTasks(connectionId)
|
||||
|
||||
const totalCPU = nodes?.reduce((acc, n) => acc + n.maxcpu, 0) ?? 0
|
||||
const usedCPU = nodes?.reduce((acc, n) => acc + n.cpu * n.maxcpu, 0) ?? 0
|
||||
const totalMem = nodes?.reduce((acc, n) => acc + n.maxmem, 0) ?? 0
|
||||
const usedMem = nodes?.reduce((acc, n) => acc + n.mem, 0) ?? 0
|
||||
const totalDisk = nodes?.reduce((acc, n) => acc + n.maxdisk, 0) ?? 0
|
||||
const usedDisk = nodes?.reduce((acc, n) => acc + n.disk, 0) ?? 0
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.nodes(connectionId) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.vms(connectionId) })
|
||||
queryClient.invalidateQueries({ 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 (
|
||||
<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>
|
||||
</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>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{nodes?.filter((n) => n.status === 'online').length ?? 0} / {nodes?.length ?? 0}
|
||||
</div>
|
||||
<p className="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>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
|
||||
</div>
|
||||
<p className="text-xs 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>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'}
|
||||
</div>
|
||||
<p className="text-xs 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>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(usedDisk)} / {formatBytes(totalDisk)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resource Gauges */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ResourceGauge
|
||||
label="CPU"
|
||||
used={usedCPU}
|
||||
total={totalCPU}
|
||||
icon="cpu"
|
||||
formatValue={(v) => `${v.toFixed(1)} cores`}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Memory"
|
||||
used={usedMem}
|
||||
total={totalMem}
|
||||
icon="memory"
|
||||
formatValue={formatBytes}
|
||||
/>
|
||||
<ResourceGauge
|
||||
label="Storage"
|
||||
used={usedDisk}
|
||||
total={totalDisk}
|
||||
icon="disk"
|
||||
formatValue={formatBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Node Health Grid */}
|
||||
<NodeHealthGrid nodes={nodes} vms={vms} />
|
||||
|
||||
{/* Activity Feed + Quick Actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2">
|
||||
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<QuickActions onRefresh={handleRefresh} isRefreshing={tasksLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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 { cn } from '@/lib/utils'
|
||||
|
||||
type ViewType = 'dashboard' | 'vms' | 'vm-detail' | 'tasks' | 'backups' | 'storage' | 'storage-detail' | 'settings'
|
||||
|
||||
interface SidebarProps {
|
||||
onAddConnection: () => void
|
||||
activeView?: ViewType
|
||||
onNavigate?: (view: { type: ViewType }) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProps) {
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
case 'connecting':
|
||||
case 'failover':
|
||||
return 'bg-yellow-500'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{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',
|
||||
activeConnectionId === connection.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-2 h-2 rounded-full', getStatusColor(connection.status))} />
|
||||
<span className="font-medium truncate">{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" />
|
||||
<SidebarItem
|
||||
icon={Box}
|
||||
label="VMs"
|
||||
active={activeView === 'vms' || activeView === 'vm-detail'}
|
||||
onClick={() => onNavigate?.({ type: 'vms' })}
|
||||
/>
|
||||
<SidebarItem icon={Box} label="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' })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="p-2 border-t space-y-1">
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
active={activeView === 'settings'}
|
||||
onClick={() => onNavigate?.({ type: 'settings' })}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={onAddConnection}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Server, Cpu, MemoryStick, HardDrive } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxNode } from '@/types/proxmox'
|
||||
|
||||
interface NodeDetailProps {
|
||||
node: ProxmoxNode
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
function ResourceBar({ label, used, total, icon: Icon }: {
|
||||
label: string
|
||||
used: number
|
||||
total: number
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
const percent = total > 0 ? (used / total) * 100 : 0
|
||||
const color =
|
||||
percent > 90 ? 'bg-red-500' :
|
||||
percent > 70 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</div>
|
||||
<span className="text-sm 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}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(used)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeDetail({ node, connectionId }: NodeDetailProps) {
|
||||
const { data: vms, isLoading: vmsLoading } = useVMs(connectionId)
|
||||
|
||||
const nodeVMs = vms?.filter((vm) => vm.node === node.node) ?? []
|
||||
const runningVMs = nodeVMs.filter((vm) => vm.status === 'running')
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Uptime: {formatUptime(node.uptime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Usage */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ResourceBar
|
||||
label="CPU"
|
||||
used={node.cpu * node.maxcpu}
|
||||
total={node.maxcpu}
|
||||
icon={Cpu}
|
||||
/>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{node.cpu.toFixed(1)} / {node.maxcpu} cores
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ResourceBar
|
||||
label="Memory"
|
||||
used={node.mem}
|
||||
total={node.maxmem}
|
||||
icon={MemoryStick}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ResourceBar
|
||||
label="Disk"
|
||||
used={node.disk}
|
||||
total={node.maxdisk}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* VMs/Containers on this node */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Virtual Machines & Containers</CardTitle>
|
||||
<span className="text-sm 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>
|
||||
) : nodeVMs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No VMs or containers found on this node
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{vm.vmid}
|
||||
</span>
|
||||
<span className="font-medium">{vm.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground uppercase">
|
||||
{vm.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge status={vm.status} />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vm.maxmem > 0 ? formatBytes(vm.mem) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Node Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Node Name</span>
|
||||
<p className="font-medium">{node.node}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<p className="font-medium capitalize">{node.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Uptime</span>
|
||||
<p className="font-medium">{formatUptime(node.uptime)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Support Level</span>
|
||||
<p className="font-medium">{node.level || 'None'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total CPUs</span>
|
||||
<p className="font-medium">{node.maxcpu}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Memory</span>
|
||||
<p className="font-medium">{formatBytes(node.maxmem)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Total Disk</span>
|
||||
<p className="font-medium">{formatBytes(node.maxdisk)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">ID</span>
|
||||
<p className="font-medium font-mono text-xs">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { useConnectionStore } from '@/stores/connectionStore'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConnectionDialog } from '@/components/connections/ConnectionDialog'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ConnectionManager() {
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const removeConnection = useConnectionStore((s) => s.removeConnection)
|
||||
const { addToast } = useToast()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
case 'connecting':
|
||||
case 'failover':
|
||||
return 'bg-yellow-500'
|
||||
case 'failed':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
removeConnection(id)
|
||||
setDeleteConfirmId(null)
|
||||
addToast(`Connection "${name}" removed`, 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm 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" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No connections configured. Add a server to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{connections.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
getStatusColor(connection.status)
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{connection.name}</p>
|
||||
<p className="text-xs 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { ThemeSwitcher } from '@/components/settings/ThemeSwitcher'
|
||||
import { ConnectionManager } from '@/components/settings/ConnectionManager'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Manage your application preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the look and feel of the application
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Theme</label>
|
||||
<ThemeSwitcher />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select your preferred color scheme. System will match your OS setting.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connections</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your Proxmox server connections
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConnectionManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About</CardTitle>
|
||||
<CardDescription>Application information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<a
|
||||
href="https://github.com/proxmoxdesktop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
View on GitHub
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Sun, Moon, Monitor } from 'lucide-react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
const themeOptions: { value: Theme; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
]
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const theme = useUIStore((s) => s.theme)
|
||||
const setTheme = useUIStore((s) => s.setTheme)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{themeOptions.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<button
|
||||
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',
|
||||
theme === option.value
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { HardDrive, Database, Archive, Disc, Box } from 'lucide-react'
|
||||
import type { ProxmoxStorage } from '@/types/proxmox'
|
||||
|
||||
interface StorageCardProps {
|
||||
storage: ProxmoxStorage
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function getStorageIcon(type: string) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'lvm':
|
||||
case 'lvmthin':
|
||||
return Database
|
||||
case 'zfs':
|
||||
case 'zfspool':
|
||||
return Database
|
||||
case 'nfs':
|
||||
case 'cifs':
|
||||
case 'glusterfs':
|
||||
return HardDrive
|
||||
case 'local':
|
||||
return HardDrive
|
||||
default:
|
||||
return HardDrive
|
||||
}
|
||||
}
|
||||
|
||||
function getContentBadges(content: string): string[] {
|
||||
if (!content) return []
|
||||
return content.split(',').map((c) => c.trim())
|
||||
}
|
||||
|
||||
const contentTypeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
images: Box,
|
||||
rootdir: Box,
|
||||
backup: Archive,
|
||||
iso: Disc,
|
||||
snippets: HardDrive,
|
||||
'vztmpl': HardDrive,
|
||||
}
|
||||
|
||||
function getContentTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'images': return 'VM Images'
|
||||
case 'rootdir': return 'Containers'
|
||||
case 'backup': return 'Backups'
|
||||
case 'iso': return 'ISOs'
|
||||
case 'snippets': return 'Snippets'
|
||||
case 'vztmpl': return 'Templates'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageCard({ storage, onClick }: StorageCardProps) {
|
||||
const percent = storage.total > 0 ? (storage.used / storage.total) * 100 : 0
|
||||
const usageColor = getUsageColor(percent)
|
||||
const Icon = getStorageIcon(storage.type)
|
||||
const contentTypes = getContentBadges(storage.content)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
{storage.storage}
|
||||
</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>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Usage Bar */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${usageColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Info */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatBytes(storage.used)} used</span>
|
||||
<span>{formatBytes(storage.total)} total</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(storage.avail)} available
|
||||
</div>
|
||||
|
||||
{/* Content Types */}
|
||||
{contentTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{contentTypes.map((type) => {
|
||||
const TypeIcon = contentTypeIcons[type] || HardDrive
|
||||
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"
|
||||
>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{getContentTypeLabel(type)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, HardDrive, Database, Archive, Disc, Box, Clock, File } from 'lucide-react'
|
||||
import { useStorageDetail, useStorageContent } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxStorageContent } from '@/types/proxmox'
|
||||
|
||||
interface StorageDetailProps {
|
||||
connectionId: string
|
||||
storage: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function getUsageColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500'
|
||||
if (percent >= 70) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
function getStorageIcon(type: string) {
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'lvm':
|
||||
case 'lvmthin':
|
||||
return Database
|
||||
case 'zfs':
|
||||
case 'zfspool':
|
||||
return Database
|
||||
default:
|
||||
return HardDrive
|
||||
}
|
||||
}
|
||||
|
||||
function getContentIcon(contentType: string) {
|
||||
switch (contentType) {
|
||||
case 'images': return Box
|
||||
case 'backup': return Archive
|
||||
case 'iso': return Disc
|
||||
default: return File
|
||||
}
|
||||
}
|
||||
|
||||
function getContentTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'images': return 'VM Images'
|
||||
case 'rootdir': return 'Container Root Disks'
|
||||
case 'backup': return 'Backups'
|
||||
case 'iso': return 'ISO Images'
|
||||
case 'snippets': return 'Snippets'
|
||||
case 'vztmpl': return 'Container Templates'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (!seconds) return 'N/A'
|
||||
return new Date(seconds * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
function formatContentItem(item: ProxmoxStorageContent): string {
|
||||
const parts = item.volid.split('/')
|
||||
return parts[parts.length - 1] || item.volid
|
||||
}
|
||||
|
||||
export function StorageDetail({ connectionId, storage, onBack }: StorageDetailProps) {
|
||||
const { data: detail, isLoading: detailLoading } = useStorageDetail(
|
||||
connectionId,
|
||||
null,
|
||||
storage
|
||||
)
|
||||
const { data: contentList, isLoading: contentLoading } = useStorageContent(
|
||||
connectionId,
|
||||
storage
|
||||
)
|
||||
|
||||
const percent = detail && detail.total > 0
|
||||
? (detail.used / detail.total) * 100
|
||||
: 0
|
||||
const usageColor = getUsageColor(percent)
|
||||
const Icon = detail ? getStorageIcon(detail.type) : HardDrive
|
||||
|
||||
// Group content by type
|
||||
const contentByType = contentList?.reduce((acc, item) => {
|
||||
const type = item.content
|
||||
if (!acc[type]) acc[type] = []
|
||||
acc[type].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, ProxmoxStorageContent[]>) ?? {}
|
||||
|
||||
if (detailLoading || contentLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading storage details...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load storage details</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{detail.type.toUpperCase()} storage on {detail.node}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Usage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Resource Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Large Usage Bar */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="h-4 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${usageColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatBytes(detail.used)} used</span>
|
||||
<span>{formatBytes(detail.total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<p className="text-sm font-medium">
|
||||
{detail.active ? (
|
||||
<span className="text-green-600">Active</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Inactive</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Storage Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
<p className="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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Node</p>
|
||||
<p className="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-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-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-sm font-medium">{detail.content || 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Content
|
||||
{contentList && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({contentList.length} items)
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!contentList || contentList.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No content found</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(contentByType).map(([type, items]) => {
|
||||
const TypeIcon = getContentIcon(type)
|
||||
return (
|
||||
<div key={type} className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<TypeIcon className="h-4 w-4 text-muted-foreground" />
|
||||
{getContentTypeLabel(type)}
|
||||
<span className="text-muted-foreground">({items.length})</span>
|
||||
</div>
|
||||
<div className="ml-6 space-y-1">
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={`${item.volid}-${idx}`}
|
||||
className="flex items-center justify-between py-1 text-sm border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate max-w-[300px]">
|
||||
{formatContentItem(item)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{item.format && (
|
||||
<span className="uppercase bg-muted px-1.5 py-0.5 rounded">
|
||||
{item.format}
|
||||
</span>
|
||||
)}
|
||||
{item.size && <span>{formatBytes(item.size)}</span>}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimestamp(item.ctime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useStorage } from '@/hooks/useProxmox'
|
||||
import { StorageCard } from './StorageCard'
|
||||
import { Search, HardDrive } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface StorageOverviewProps {
|
||||
connectionId: string
|
||||
onStorageClick?: (storage: string) => void
|
||||
}
|
||||
|
||||
export function StorageOverview({ connectionId, onStorageClick }: StorageOverviewProps) {
|
||||
const { data: storageList, isLoading, error } = useStorage(connectionId)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filteredStorage = useMemo(() => {
|
||||
if (!storageList) return []
|
||||
if (!search) return storageList
|
||||
const query = search.toLowerCase()
|
||||
return storageList.filter(
|
||||
(s) =>
|
||||
s.storage.toLowerCase().includes(query) ||
|
||||
s.type.toLowerCase().includes(query) ||
|
||||
s.node.toLowerCase().includes(query)
|
||||
)
|
||||
}, [storageList, search])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading storage pools...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load storage pools</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Storage Pools</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{storageList?.length ?? 0} storage pools across the cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name, type, or node..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredStorage.map((storage) => (
|
||||
<StorageCard
|
||||
key={`${storage.node}-${storage.storage}`}
|
||||
storage={storage}
|
||||
onClick={() => onStorageClick?.(storage.storage)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
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 { useTasks } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxTask } from '@/types/proxmox'
|
||||
|
||||
interface TaskListProps {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
type TaskStatusFilter = 'all' | 'running' | 'completed' | 'failed'
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const date = new Date(seconds * 1000)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(start: number, end?: number): string {
|
||||
if (start === 0) return 'N/A'
|
||||
const endTime = end ?? Math.floor(Date.now() / 1000)
|
||||
const duration = endTime - start
|
||||
if (duration < 0) return 'N/A'
|
||||
const hours = Math.floor(duration / 3600)
|
||||
const minutes = Math.floor((duration % 3600) / 60)
|
||||
const seconds = duration % 60
|
||||
const parts: string[] = []
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function getTaskStatus(task: ProxmoxTask): 'running' | 'completed' | 'failed' {
|
||||
if (task.status === 'running') return 'running'
|
||||
if (task.exitstatus === 'OK') return 'completed'
|
||||
if (task.exitstatus && task.exitstatus !== 'OK') return 'failed'
|
||||
// If no endtime, consider it running
|
||||
if (!task.endtime) return 'running'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function TaskStatusIcon({ status }: { status: 'running' | 'completed' | 'failed' }) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Play className="h-3.5 w-3.5 text-blue-500" />
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
const label = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config[status]}`}
|
||||
>
|
||||
<TaskStatusIcon status={status} />
|
||||
{label[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskList({ connectionId }: TaskListProps) {
|
||||
const { data: tasks, isLoading, error } = useTasks(connectionId)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatusFilter>('all')
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all')
|
||||
const [userFilter, setUserFilter] = useState<string>('all')
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set())
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!tasks) return []
|
||||
return tasks
|
||||
.filter((task) => {
|
||||
const taskStatus = getTaskStatus(task)
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && taskStatus !== statusFilter) return false
|
||||
// Node filter
|
||||
if (nodeFilter !== 'all' && task.node !== nodeFilter) return false
|
||||
// User filter
|
||||
if (userFilter !== 'all' && task.user !== userFilter) return false
|
||||
// Search filter
|
||||
if (search) {
|
||||
const query = search.toLowerCase()
|
||||
const matchesUPID = task.upid.toLowerCase().includes(query)
|
||||
const matchesType = task.type.toLowerCase().includes(query)
|
||||
const matchesId = task.id.toLowerCase().includes(query)
|
||||
if (!matchesUPID && !matchesType && !matchesId) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by start time descending (newest first)
|
||||
return b.starttime - a.starttime
|
||||
})
|
||||
}, [tasks, statusFilter, nodeFilter, userFilter, search])
|
||||
|
||||
const uniqueNodes = useMemo(() => {
|
||||
if (!tasks) return []
|
||||
return [...new Set(tasks.map((t) => t.node))].sort()
|
||||
}, [tasks])
|
||||
|
||||
const uniqueUsers = useMemo(() => {
|
||||
if (!tasks) return []
|
||||
return [...new Set(tasks.map((t) => t.user))].sort()
|
||||
}, [tasks])
|
||||
|
||||
const runningCount = useMemo(() => {
|
||||
if (!tasks) return 0
|
||||
return tasks.filter((t) => getTaskStatus(t) === 'running').length
|
||||
}, [tasks])
|
||||
|
||||
const toggleExpanded = (upid: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(upid)) {
|
||||
next.delete(upid)
|
||||
} else {
|
||||
next.add(upid)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading tasks...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load tasks</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-6 w-6" />
|
||||
<h2 className="text-2xl font-semibold">Tasks</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{tasks?.length ?? 0} total tasks
|
||||
{runningCount > 0 && (
|
||||
<span className="ml-2 text-blue-500 font-medium">
|
||||
({runningCount} running)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by UPID, type, or ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as TaskStatusFilter)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={nodeFilter}
|
||||
onChange={(e) => setNodeFilter(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Nodes</option>
|
||||
{uniqueNodes.map((node) => (
|
||||
<option key={node} value={node}>
|
||||
{node}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Users</option>
|
||||
{uniqueUsers.map((user) => (
|
||||
<option key={user} value={user}>
|
||||
{user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Task Table */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((task) => {
|
||||
const taskStatus = getTaskStatus(task)
|
||||
const isExpanded = expandedTasks.has(task.upid)
|
||||
return (
|
||||
<Fragment key={task.upid}>
|
||||
<tr
|
||||
className="border-b last:border-b-0 hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => toggleExpanded(task.upid)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<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">
|
||||
<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 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">
|
||||
<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">
|
||||
{formatDuration(task.starttime, task.endtime)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${task.upid}-details`} className="border-b bg-muted/20">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">UPID:</span>
|
||||
<p className="font-mono">{task.upid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Task ID:</span>
|
||||
<p className="font-mono">{task.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">PID:</span>
|
||||
<p className="font-mono">{task.pid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">PStart:</span>
|
||||
<p className="font-mono">{task.pstart}</p>
|
||||
</div>
|
||||
{task.endtime && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<p>{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'}>
|
||||
{task.exitstatus}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ListTodo, Play } from 'lucide-react'
|
||||
import { useTasks } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxTask } from '@/types/proxmox'
|
||||
|
||||
interface TaskStatusBarProps {
|
||||
connectionId: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function getTaskStatus(task: ProxmoxTask): 'running' | 'completed' | 'failed' {
|
||||
if (task.status === 'running') return 'running'
|
||||
if (task.exitstatus === 'OK') return 'completed'
|
||||
if (task.exitstatus && task.exitstatus !== 'OK') return 'failed'
|
||||
if (!task.endtime) return 'running'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
|
||||
const { data: tasks } = useTasks(connectionId)
|
||||
|
||||
const runningCount = useMemo(() => {
|
||||
if (!tasks) return 0
|
||||
return tasks.filter((t) => getTaskStatus(t) === 'running').length
|
||||
}, [tasks])
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<span>No tasks running</span>
|
||||
)}
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<Play className="h-3 w-3 text-blue-500 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
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',
|
||||
{
|
||||
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',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<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',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
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'
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle, XCircle, Pause, Clock } from 'lucide-react'
|
||||
|
||||
type BadgeStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'online' | 'offline'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: BadgeStatus
|
||||
className?: string
|
||||
}
|
||||
|
||||
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',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
stopped: {
|
||||
label: 'Stopped',
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
|
||||
icon: XCircle,
|
||||
},
|
||||
paused: {
|
||||
label: 'Paused',
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
icon: Pause,
|
||||
},
|
||||
suspended: {
|
||||
label: 'Suspended',
|
||||
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
icon: Clock,
|
||||
},
|
||||
online: {
|
||||
label: 'Online',
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
offline: {
|
||||
label: 'Offline',
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
icon: XCircle,
|
||||
},
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: StatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||||
|
||||
type ToastVariant = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
addToast: (message: string, variant?: ToastVariant) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const addToast = useCallback(
|
||||
(message: string, variant: ToastVariant = 'info') => {
|
||||
const id = crypto.randomUUID()
|
||||
setToasts((prev) => [...prev, { id, message, variant }])
|
||||
setTimeout(() => removeToast(id), 5000)
|
||||
},
|
||||
[removeToast]
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastContainer({
|
||||
toasts,
|
||||
onRemove,
|
||||
}: {
|
||||
toasts: Toast[]
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, { icon: React.ComponentType<{ className?: string }>; container: 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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
toast,
|
||||
onRemove,
|
||||
}: {
|
||||
toast: Toast
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
const config = variantStyles[toast.variant]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-4 shadow-lg',
|
||||
config.container
|
||||
)}
|
||||
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>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="shrink-0 rounded-md p-0.5 opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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 { Search, Server, Box } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface ContainerListProps {
|
||||
connectionId: string
|
||||
onContainerClick?: (container: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
type ContainerStatusFilter = 'all' | ProxmoxVM['status']
|
||||
|
||||
export function ContainerList({ connectionId, onContainerClick }: ContainerListProps) {
|
||||
const { data: vms, isLoading, error } = useVMs(connectionId)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<ContainerStatusFilter>('all')
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all')
|
||||
|
||||
// Filter to only LXC containers
|
||||
const containers = useMemo(() => {
|
||||
return vms?.filter((vm) => vm.type === 'lxc') ?? []
|
||||
}, [vms])
|
||||
|
||||
const filteredContainers = useMemo(() => {
|
||||
return containers.filter((container) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && container.status !== statusFilter) return false
|
||||
// Node filter
|
||||
if (nodeFilter !== 'all' && container.node !== nodeFilter) return false
|
||||
// Search filter
|
||||
if (search) {
|
||||
const query = search.toLowerCase()
|
||||
const matchesName = container.name.toLowerCase().includes(query)
|
||||
const matchesVMID = container.vmid.toString().includes(query)
|
||||
if (!matchesName && !matchesVMID) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [containers, statusFilter, nodeFilter, search])
|
||||
|
||||
const uniqueNodes = useMemo(() => {
|
||||
return [...new Set(containers.map((c) => c.node))].sort()
|
||||
}, [containers])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading containers...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load containers</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Containers</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{containers.length} total containers across the cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or VMID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as ContainerStatusFilter)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="stopped">Stopped</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={nodeFilter}
|
||||
onChange={(e) => setNodeFilter(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Nodes</option>
|
||||
{uniqueNodes.map((node) => (
|
||||
<option key={node} value={node}>
|
||||
{node}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Container Table */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContainers.map((container) => (
|
||||
<tr
|
||||
key={`lxc-${container.vmid}`}
|
||||
className={`border-b last:border-b-0 hover:bg-muted/50 transition-colors ${
|
||||
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-medium">{container.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={container.status} />
|
||||
</td>
|
||||
<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}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">
|
||||
{container.cpus} cores
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{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">
|
||||
{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">
|
||||
{formatUptime(container.uptime)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StatusBadge } from '@/components/ui/status-badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Square,
|
||||
Power,
|
||||
RotateCw,
|
||||
Pause,
|
||||
PlayCircle,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react'
|
||||
import { useStartVM, useStopVM, useShutdownVM, useRebootVM, useSuspendVM, useResumeVM, useClusterStatus } from '@/hooks/useProxmox'
|
||||
import { OverviewTab } from '@/components/vms/tabs/OverviewTab'
|
||||
import { HardwareTab } from '@/components/vms/tabs/HardwareTab'
|
||||
import { DisksTab } from '@/components/vms/tabs/DisksTab'
|
||||
import { NetworkTab } from '@/components/vms/tabs/NetworkTab'
|
||||
import { SnapshotsTab } from '@/components/vms/tabs/SnapshotsTab'
|
||||
import { ConsoleTab } from '@/components/vms/tabs/ConsoleTab'
|
||||
import { MigrateDialog } from '@/components/vms/dialogs/MigrateDialog'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface VMDetailProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type ConfirmAction = {
|
||||
type: 'stop' | 'shutdown' | 'reboot'
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function VMDetail({ vm, connectionId, onBack }: VMDetailProps) {
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
|
||||
const [migrateDialogOpen, setMigrateDialogOpen] = useState(false)
|
||||
|
||||
const startVM = useStartVM()
|
||||
const stopVM = useStopVM()
|
||||
const shutdownVM = useShutdownVM()
|
||||
const rebootVM = useRebootVM()
|
||||
const suspendVM = useSuspendVM()
|
||||
const resumeVM = useResumeVM()
|
||||
const clusterStatus = useClusterStatus(connectionId)
|
||||
|
||||
const isRunning = vm.status === 'running'
|
||||
const isStopped = vm.status === 'stopped'
|
||||
const isBusy = startVM.isPending || stopVM.isPending || shutdownVM.isPending || rebootVM.isPending || suspendVM.isPending || resumeVM.isPending
|
||||
|
||||
const isCluster = clusterStatus.data?.type === 'cluster' && (clusterStatus.data?.nodes?.length ?? 0) > 1
|
||||
|
||||
const handleStart = () => {
|
||||
startVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
setConfirmAction({
|
||||
type: 'stop',
|
||||
title: 'Force Stop VM',
|
||||
description: `Are you sure you want to force stop "${vm.name}"? This is equivalent to pulling the power plug and may cause data loss.`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleShutdown = () => {
|
||||
setConfirmAction({
|
||||
type: 'shutdown',
|
||||
title: 'Shutdown VM',
|
||||
description: `Are you sure you want to gracefully shutdown "${vm.name}"? The VM will have a chance to save its state.`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleReboot = () => {
|
||||
setConfirmAction({
|
||||
type: 'reboot',
|
||||
title: 'Reboot VM',
|
||||
description: `Are you sure you want to reboot "${vm.name}"? The VM will be restarted gracefully.`,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSuspend = () => {
|
||||
suspendVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
}
|
||||
|
||||
const handleResume = () => {
|
||||
resumeVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
}
|
||||
|
||||
const executeConfirmAction = () => {
|
||||
if (!confirmAction) return
|
||||
|
||||
switch (confirmAction.type) {
|
||||
case 'stop':
|
||||
stopVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
break
|
||||
case 'shutdown':
|
||||
shutdownVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
break
|
||||
case 'reboot':
|
||||
rebootVM.mutate({ node: vm.node, vmid: vm.vmid })
|
||||
break
|
||||
}
|
||||
|
||||
setConfirmAction(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold">{vm.name}</h2>
|
||||
<StatusBadge status={vm.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
VMID {vm.vmid} · {vm.type.toUpperCase()} · {vm.node}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lifecycle Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isStopped || !isRunning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Start
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleShutdown}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Power className="h-4 w-4 mr-1" />
|
||||
Shutdown
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReboot}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RotateCw className="h-4 w-4 mr-1" />
|
||||
Reboot
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSuspend}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
Suspend
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{vm.status === 'paused' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResume}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<PlayCircle className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCluster && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setMigrateDialogOpen(true)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4 mr-1" />
|
||||
Migrate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
<TabsTrigger value="hardware">Hardware</TabsTrigger>
|
||||
<TabsTrigger value="disks">Disks</TabsTrigger>
|
||||
<TabsTrigger value="network">Network</TabsTrigger>
|
||||
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="console">
|
||||
<ConsoleTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hardware">
|
||||
<HardwareTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disks">
|
||||
<DisksTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network">
|
||||
<NetworkTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="snapshots">
|
||||
<SnapshotsTab vm={vm} connectionId={connectionId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<Dialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{confirmAction?.title}</DialogTitle>
|
||||
<DialogDescription>{confirmAction?.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmAction(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={executeConfirmAction}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Migrate Dialog */}
|
||||
<MigrateDialog
|
||||
vm={vm}
|
||||
connectionId={connectionId}
|
||||
open={migrateDialogOpen}
|
||||
onOpenChange={setMigrateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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 { Search, Server, Box } from 'lucide-react'
|
||||
import { useVMs } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface VMListProps {
|
||||
connectionId: string
|
||||
onVMClick?: (vm: ProxmoxVM) => void
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds === 0) return 'N/A'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
if (minutes > 0) parts.push(`${minutes}m`)
|
||||
return parts.join(' ') || '< 1m'
|
||||
}
|
||||
|
||||
type VMTypeFilter = 'all' | 'qemu' | 'lxc'
|
||||
type VMStatusFilter = 'all' | ProxmoxVM['status']
|
||||
|
||||
export function VMList({ connectionId, onVMClick }: VMListProps) {
|
||||
const { data: vms, isLoading: vmsLoading, error: vmsError } = useVMs(connectionId)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<VMTypeFilter>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<VMStatusFilter>('all')
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all')
|
||||
|
||||
const filteredVMs = useMemo(() => {
|
||||
if (!vms) return []
|
||||
return vms.filter((vm) => {
|
||||
// Type filter
|
||||
if (typeFilter !== 'all' && vm.type !== typeFilter) return false
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && vm.status !== statusFilter) return false
|
||||
// Node filter
|
||||
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false
|
||||
// Search filter
|
||||
if (search) {
|
||||
const query = search.toLowerCase()
|
||||
const matchesName = vm.name.toLowerCase().includes(query)
|
||||
const matchesVMID = vm.vmid.toString().includes(query)
|
||||
if (!matchesName && !matchesVMID) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [vms, typeFilter, statusFilter, nodeFilter, search])
|
||||
|
||||
const uniqueNodes = useMemo(() => {
|
||||
if (!vms) return []
|
||||
return [...new Set(vms.map((vm) => vm.node))].sort()
|
||||
}, [vms])
|
||||
|
||||
if (vmsLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading VMs...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (vmsError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive">Failed to load VMs</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Virtual Machines</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{vms?.length ?? 0} total VMs across the cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or VMID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as VMTypeFilter)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="qemu">VM (QEMU)</option>
|
||||
<option value="lxc">Container (LXC)</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as VMStatusFilter)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="stopped">Stopped</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={nodeFilter}
|
||||
onChange={(e) => setNodeFilter(e.target.value)}
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="all">All Nodes</option>
|
||||
{uniqueNodes.map((node) => (
|
||||
<option key={node} value={node}>
|
||||
{node}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* VM Table */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredVMs.map((vm) => (
|
||||
<tr
|
||||
key={`${vm.type}-${vm.vmid}`}
|
||||
className={`border-b last:border-b-0 hover:bg-muted/50 transition-colors ${
|
||||
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-medium">{vm.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={vm.status} />
|
||||
</td>
|
||||
<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}
|
||||
</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">
|
||||
{vm.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">
|
||||
{vm.cpus} cores
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{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">
|
||||
{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">
|
||||
{formatUptime(vm.uptime)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useAddDisk } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM, AddDiskConfig } from '@/types/proxmox'
|
||||
|
||||
interface AddDiskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
}
|
||||
|
||||
export function AddDiskDialog({ open, onOpenChange, vm }: AddDiskDialogProps) {
|
||||
const [storage, setStorage] = useState('local-lvm')
|
||||
const [size, setSize] = useState('32')
|
||||
const [busType, setBusType] = useState<AddDiskConfig['busType']>('scsi')
|
||||
const addDisk = useAddDisk()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const sizeBytes = parseFloat(size) * 1024 * 1024 * 1024
|
||||
addDisk.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
config: { storage, size: sizeBytes, busType },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
setStorage('local-lvm')
|
||||
setSize('32')
|
||||
setBusType('scsi')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Disk</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new disk to {vm.name} (VMID {vm.vmid})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="storage">Storage</Label>
|
||||
<Input
|
||||
id="storage"
|
||||
value={storage}
|
||||
onChange={(e) => setStorage(e.target.value)}
|
||||
placeholder="local-lvm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="size">Size (GB)</Label>
|
||||
<Input
|
||||
id="size"
|
||||
type="number"
|
||||
min={1}
|
||||
value={size}
|
||||
onChange={(e) => setSize(e.target.value)}
|
||||
placeholder="32"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="busType">Bus Type</Label>
|
||||
<select
|
||||
id="busType"
|
||||
value={busType}
|
||||
onChange={(e) => setBusType(e.target.value as AddDiskConfig['busType'])}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="scsi">SCSI</option>
|
||||
<option value="virtio">VirtIO</option>
|
||||
<option value="ide">IDE</option>
|
||||
<option value="sata">SATA</option>
|
||||
</select>
|
||||
</div>
|
||||
{addDisk.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to add disk. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addDisk.isPending}>
|
||||
{addDisk.isPending ? 'Adding...' : 'Add Disk'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useAddNIC } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface AddNICDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
}
|
||||
|
||||
export function AddNICDialog({ open, onOpenChange, vm }: AddNICDialogProps) {
|
||||
const [bridge, setBridge] = useState('vmbr0')
|
||||
const [model, setModel] = useState('virtio')
|
||||
const [macaddr, setMacaddr] = useState('')
|
||||
const [tag, setTag] = useState('')
|
||||
const [firewall, setFirewall] = useState(false)
|
||||
const addNIC = useAddNIC()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
addNIC.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
config: {
|
||||
bridge,
|
||||
model,
|
||||
macaddr: macaddr || undefined,
|
||||
tag: tag ? parseInt(tag, 10) : undefined,
|
||||
firewall,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
setBridge('vmbr0')
|
||||
setModel('virtio')
|
||||
setMacaddr('')
|
||||
setTag('')
|
||||
setFirewall(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Network Interface</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new NIC to {vm.name} (VMID {vm.vmid})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bridge">Bridge</Label>
|
||||
<Input
|
||||
id="bridge"
|
||||
value={bridge}
|
||||
onChange={(e) => setBridge(e.target.value)}
|
||||
placeholder="vmbr0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<select
|
||||
id="model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="virtio">VirtIO (paravirtualized)</option>
|
||||
<option value="e1000">Intel E1000</option>
|
||||
<option value="rtl8139">Realtek RTL8139</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="macaddr">MAC Address (optional)</Label>
|
||||
<Input
|
||||
id="macaddr"
|
||||
value={macaddr}
|
||||
onChange={(e) => setMacaddr(e.target.value)}
|
||||
placeholder="auto-generated if empty"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tag">VLAN Tag (optional)</Label>
|
||||
<Input
|
||||
id="tag"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4094}
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="firewall"
|
||||
type="checkbox"
|
||||
checked={firewall}
|
||||
onChange={(e) => setFirewall(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="firewall" className="cursor-pointer">
|
||||
Enable firewall
|
||||
</Label>
|
||||
</div>
|
||||
{addNIC.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to add network interface. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addNIC.isPending}>
|
||||
{addNIC.isPending ? 'Adding...' : 'Add NIC'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useCreateSnapshot } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface CreateSnapshotDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
}
|
||||
|
||||
export function CreateSnapshotDialog({ open, onOpenChange, vm }: CreateSnapshotDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [vmstate, setVmstate] = useState(false)
|
||||
const createSnapshot = useCreateSnapshot()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
createSnapshot.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
config: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
vmstate,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
setDescription('')
|
||||
setVmstate(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Snapshot</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new snapshot for {vm.name} (VMID {vm.vmid})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Snapshot Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="before-update"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Before applying system updates"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="vmstate"
|
||||
checked={vmstate}
|
||||
onChange={(e) => setVmstate(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="vmstate" className="text-sm font-normal cursor-pointer">
|
||||
Include VM state (memory)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Including VM state captures the running memory, allowing rollback to the exact running
|
||||
state. This requires more disk space.
|
||||
</p>
|
||||
{createSnapshot.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to create snapshot. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createSnapshot.isPending || !name.trim()}>
|
||||
{createSnapshot.isPending ? 'Creating...' : 'Create Snapshot'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useEditNIC } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM, ProxmoxNetwork } from '@/types/proxmox'
|
||||
|
||||
interface EditNICDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
nic: ProxmoxNetwork
|
||||
}
|
||||
|
||||
export function EditNICDialog({ open, onOpenChange, vm, nic }: EditNICDialogProps) {
|
||||
const [bridge, setBridge] = useState(nic.bridge ?? '')
|
||||
const [model, setModel] = useState(nic.model)
|
||||
const [tag, setTag] = useState(nic.tag != null ? String(nic.tag) : '')
|
||||
const [firewall, setFirewall] = useState(nic.firewall === 1)
|
||||
const editNIC = useEditNIC()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
editNIC.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
nic: nic.name,
|
||||
config: {
|
||||
bridge: bridge || undefined,
|
||||
model,
|
||||
tag: tag ? parseInt(tag, 10) : undefined,
|
||||
firewall,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Network Interface</DialogTitle>
|
||||
<DialogDescription>
|
||||
Edit {nic.name} ({nic.model}) on {vm.name} (VMID {vm.vmid})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-bridge">Bridge</Label>
|
||||
<Input
|
||||
id="edit-bridge"
|
||||
value={bridge}
|
||||
onChange={(e) => setBridge(e.target.value)}
|
||||
placeholder="vmbr0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-model">Model</Label>
|
||||
<select
|
||||
id="edit-model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="virtio">VirtIO (paravirtualized)</option>
|
||||
<option value="e1000">Intel E1000</option>
|
||||
<option value="rtl8139">Realtek RTL8139</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tag">VLAN Tag (optional)</Label>
|
||||
<Input
|
||||
id="edit-tag"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4094}
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
placeholder="none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="edit-firewall"
|
||||
type="checkbox"
|
||||
checked={firewall}
|
||||
onChange={(e) => setFirewall(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="edit-firewall" className="cursor-pointer">
|
||||
Enable firewall
|
||||
</Label>
|
||||
</div>
|
||||
{editNIC.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to edit network interface. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={editNIC.isPending}>
|
||||
{editNIC.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useMigrateVM, useNodes } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface MigrateDialogProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MigrateDialog({ vm, connectionId, open, onOpenChange }: MigrateDialogProps) {
|
||||
const [targetNode, setTargetNode] = useState('')
|
||||
const [online, setOnline] = useState(true)
|
||||
const migrateVM = useMigrateVM()
|
||||
const { data: nodes } = useNodes(connectionId)
|
||||
|
||||
const otherNodes = (nodes ?? []).filter((n) => n.node !== vm.node && n.status === 'online')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!targetNode.trim()) return
|
||||
|
||||
migrateVM.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
targetNode,
|
||||
online,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
setTargetNode('')
|
||||
setOnline(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Migrate VM</DialogTitle>
|
||||
<DialogDescription>
|
||||
Migrate {vm.name} (VMID {vm.vmid}) to another node
|
||||
</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="text-sm">
|
||||
<p className="font-medium text-yellow-600">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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetNode">Target Node</Label>
|
||||
{otherNodes.length > 0 ? (
|
||||
<select
|
||||
id="targetNode"
|
||||
value={targetNode}
|
||||
onChange={(e) => setTargetNode(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
required
|
||||
>
|
||||
<option value="">Select a node...</option>
|
||||
{otherNodes.map((node) => (
|
||||
<option key={node.node} value={node.node}>
|
||||
{node.node}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
id="targetNode"
|
||||
value={targetNode}
|
||||
onChange={(e) => setTargetNode(e.target.value)}
|
||||
placeholder="pve2"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="online"
|
||||
checked={online}
|
||||
onChange={(e) => setOnline(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<Label htmlFor="online" className="text-sm font-normal cursor-pointer">
|
||||
Online migration (VM stays running)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Online migration requires shared storage between nodes. If storage is not shared, the
|
||||
migration will transfer disk data over the network.
|
||||
</p>
|
||||
{migrateVM.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to migrate VM. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={migrateVM.isPending || !targetNode.trim()}>
|
||||
{migrateVM.isPending ? 'Migrating...' : 'Migrate'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useMoveDisk } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
|
||||
|
||||
interface MoveDiskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
disk: ProxmoxDisk
|
||||
}
|
||||
|
||||
export function MoveDiskDialog({ open, onOpenChange, vm, disk }: MoveDiskDialogProps) {
|
||||
const [targetStorage, setTargetStorage] = useState(disk.storage)
|
||||
const moveDisk = useMoveDisk()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
moveDisk.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
disk: disk.device,
|
||||
storage: targetStorage,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Disk</DialogTitle>
|
||||
<DialogDescription>
|
||||
Move {disk.device} from {disk.storage} to a different storage
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetStorage">Target Storage</Label>
|
||||
<Input
|
||||
id="targetStorage"
|
||||
value={targetStorage}
|
||||
onChange={(e) => setTargetStorage(e.target.value)}
|
||||
placeholder="local-lvm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{moveDisk.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to move disk. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={moveDisk.isPending}>
|
||||
{moveDisk.isPending ? 'Moving...' : 'Move Disk'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useResizeDisk } from '@/hooks/useProxmox'
|
||||
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
|
||||
|
||||
interface ResizeDiskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
vm: ProxmoxVM
|
||||
disk: ProxmoxDisk
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function ResizeDiskDialog({ open, onOpenChange, vm, disk }: ResizeDiskDialogProps) {
|
||||
const currentSizeGB = Math.round(disk.size / (1024 * 1024 * 1024))
|
||||
const [newSize, setNewSize] = useState(String(currentSizeGB))
|
||||
const resizeDisk = useResizeDisk()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const sizeBytes = parseFloat(newSize) * 1024 * 1024 * 1024
|
||||
resizeDisk.mutate(
|
||||
{
|
||||
node: vm.node,
|
||||
vmid: vm.vmid,
|
||||
disk: disk.device,
|
||||
size: sizeBytes,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onOpenChange(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resize Disk</DialogTitle>
|
||||
<DialogDescription>
|
||||
Resize {disk.device} on {vm.name} (currently {formatBytes(disk.size)})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSize">New Size (GB)</Label>
|
||||
<Input
|
||||
id="newSize"
|
||||
type="number"
|
||||
min={currentSizeGB}
|
||||
value={newSize}
|
||||
onChange={(e) => setNewSize(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be larger than current size ({currentSizeGB} GB)
|
||||
</p>
|
||||
</div>
|
||||
{resizeDisk.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to resize disk. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={resizeDisk.isPending}>
|
||||
{resizeDisk.isPending ? 'Resizing...' : 'Resize'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
TerminalSquare,
|
||||
RotateCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { VNCConsole } from '@/components/console/VNCConsole'
|
||||
import { TerminalConsole } from '@/components/console/TerminalConsole'
|
||||
import type { ProxmoxVM } from '@/types/proxmox'
|
||||
|
||||
interface ConsoleTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
type ConsoleStatus = 'idle' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export function ConsoleTab({ vm, connectionId }: ConsoleTabProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [status, setStatus] = useState<ConsoleStatus>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isVM = vm.type === 'qemu'
|
||||
const canConnect = vm.status === 'running'
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
setStatus('connecting')
|
||||
setErrorMessage(null)
|
||||
}, [])
|
||||
|
||||
const handleError = useCallback((message: string) => {
|
||||
setStatus('error')
|
||||
setErrorMessage(message)
|
||||
}, [])
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setErrorMessage(null)
|
||||
}, [])
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setStatus('idle')
|
||||
setErrorMessage(null)
|
||||
// Short delay then reconnect
|
||||
setTimeout(() => {
|
||||
setStatus('connecting')
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await containerRef.current.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
await document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fullscreen toggle failed:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sendCtrlAltDel = useCallback(() => {
|
||||
// Access the VNC RFB instance through the container
|
||||
const container = containerRef.current?.querySelector('[data-vnc]')
|
||||
if (container) {
|
||||
// Dispatch a custom event that VNCConsole can listen for
|
||||
container.dispatchEvent(new CustomEvent('vnc-ctrl-alt-del'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
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 gap-2">
|
||||
{isVM ? (
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<TerminalSquare className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{isVM ? 'VNC Console' : 'Terminal Console'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({vm.name} - {vm.type.toUpperCase()})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'connecting' && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mr-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'connected' && isVM && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={sendCtrlAltDel}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Ctrl+Alt+Del
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleFullscreen}
|
||||
className="h-7 w-7 p-0"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{status === 'connected' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnect}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : status === 'error' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<RotateCw className="h-3 w-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={!canConnect}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 border border-t-0 rounded-b-lg overflow-hidden bg-black relative"
|
||||
>
|
||||
{/* Idle state - show placeholder */}
|
||||
{status === 'idle' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground gap-4 z-10">
|
||||
{isVM ? (
|
||||
<Monitor className="h-12 w-12 opacity-50" />
|
||||
) : (
|
||||
<TerminalSquare className="h-12 w-12 opacity-50" />
|
||||
)}
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{isVM ? 'VNC Console' : 'Terminal Console'}
|
||||
</p>
|
||||
{!canConnect ? (
|
||||
<p className="text-xs text-amber-500">
|
||||
VM must be running to access console
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click Connect to start a console session
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state - show error overlay */}
|
||||
{status === 'error' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground gap-4 z-10 bg-black/80">
|
||||
<AlertCircle className="h-12 w-12 text-destructive opacity-50" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Connection Failed
|
||||
</p>
|
||||
{errorMessage && (
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="mt-2"
|
||||
>
|
||||
<RotateCw className="h-3 w-3 mr-1" />
|
||||
Retry Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting state - show loading overlay */}
|
||||
{(status === 'connecting' || status === 'connected') && (
|
||||
<>
|
||||
{isVM ? (
|
||||
<VNCConsole
|
||||
connectionId={connectionId}
|
||||
node={vm.node}
|
||||
vmid={vm.vmid}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : (
|
||||
<TerminalConsole
|
||||
connectionId={connectionId}
|
||||
node={vm.node}
|
||||
vmid={vm.vmid}
|
||||
onError={handleError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground gap-4 z-10 bg-black/60">
|
||||
<Loader2 className="h-12 w-12 animate-spin opacity-50" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">Connecting...</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Establishing console connection to {vm.node}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ConsoleTabProps }
|
||||
@@ -0,0 +1,217 @@
|
||||
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 { HardDrive, Plus, Pencil, Trash, ArrowRightLeft } from 'lucide-react'
|
||||
import { useDisks, useRemoveDisk } from '@/hooks/useProxmox'
|
||||
import { AddDiskDialog } from '@/components/vms/dialogs/AddDiskDialog'
|
||||
import { ResizeDiskDialog } from '@/components/vms/dialogs/ResizeDiskDialog'
|
||||
import { MoveDiskDialog } from '@/components/vms/dialogs/MoveDiskDialog'
|
||||
import type { ProxmoxVM, ProxmoxDisk } from '@/types/proxmox'
|
||||
|
||||
interface DisksTabProps {
|
||||
vm: ProxmoxVM
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function DisksTab({ vm, connectionId }: DisksTabProps) {
|
||||
const { data: disks, isLoading, error } = useDisks(connectionId, vm.node, vm.vmid)
|
||||
const removeDisk = useRemoveDisk()
|
||||
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [resizeDisk, setResizeDisk] = useState<ProxmoxDisk | null>(null)
|
||||
const [moveDisk, setMoveDisk] = useState<ProxmoxDisk | null>(null)
|
||||
const [deleteDisk, setDeleteDisk] = useState<ProxmoxDisk | null>(null)
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteDisk) return
|
||||
removeDisk.mutate(
|
||||
{ node: vm.node, vmid: vm.vmid, disk: deleteDisk.device },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteDisk(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading disks...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-destructive">Failed to load disks</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const diskList = disks ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Disks</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{diskList.length} disk{diskList.length !== 1 ? 's' : ''} attached
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Disk
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Resize disk"
|
||||
onClick={() => setResizeDisk(disk)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title="Move disk"
|
||||
onClick={() => setMoveDisk(disk)}
|
||||
>
|
||||
<ArrowRightLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
title="Remove disk"
|
||||
onClick={() => setDeleteDisk(disk)}
|
||||
>
|
||||
<Trash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AddDiskDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
vm={vm}
|
||||
/>
|
||||
|
||||
{resizeDisk && (
|
||||
<ResizeDiskDialog
|
||||
open={!!resizeDisk}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResizeDisk(null)
|
||||
}}
|
||||
vm={vm}
|
||||
disk={resizeDisk}
|
||||
/>
|
||||
)}
|
||||
|
||||
{moveDisk && (
|
||||
<MoveDiskDialog
|
||||
open={!!moveDisk}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setMoveDisk(null)
|
||||
}}
|
||||
vm={vm}
|
||||
disk={moveDisk}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user