Initial commit

This commit is contained in:
Matt
2026-07-29 13:47:45 +00:00
commit e675d7b6af
119 changed files with 20965 additions and 0 deletions
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+265
View File
@@ -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
View File
@@ -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>
+3815
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -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

+24
View File
@@ -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

+5298
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+430
View File
@@ -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(())
}
}
+44
View File
@@ -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())
}
}
+727
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
proxmox_desktop::run()
}
+226
View File
@@ -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,
}
+253
View File
@@ -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,
}),
);
}
}
}
}
+1
View File
@@ -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":{}}
+3
View File
@@ -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/
View File
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1 @@
4609aa34aaf3f167
@@ -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}
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
+38
View File
@@ -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"
]
}
}
+1
View File
@@ -0,0 +1 @@
/* App.css - intentionally empty, using Tailwind CSS */
+168
View File
@@ -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

+1
View File
@@ -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

+57
View File
@@ -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
}
}
+410
View File
@@ -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>
)
}
+607
View File
@@ -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>
)
}
+255
View File
@@ -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 }
+117
View File
@@ -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 }
+133
View File
@@ -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} &middot; {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>
)
}
+192
View File
@@ -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>
)
}
+66
View File
@@ -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>
)
}
+106
View File
@@ -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>
)
}
+161
View File
@@ -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>
)
}
+125
View File
@@ -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>
)
}
+225
View File
@@ -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>
)
}
+79
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+134
View File
@@ -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>
)
}
+281
View File
@@ -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>
)
}
+346
View File
@@ -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>
)
}
+47
View File
@@ -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>
)
}
+52
View File
@@ -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 }
+75
View File
@@ -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 }
+116
View File
@@ -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,
}
+21
View File
@@ -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 }
+23
View File
@@ -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 }
+45
View File
@@ -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 }
+60
View File
@@ -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>
)
}
+52
View File
@@ -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 }
+120
View File
@@ -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>
)
}
+211
View File
@@ -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>
)
}
+288
View File
@@ -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} &middot; {vm.type.toUpperCase()} &middot; {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>
)
}
+228
View File
@@ -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>
)
}
+140
View File
@@ -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>
)
}
+257
View File
@@ -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 }
+217
View File
@@ -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