diff --git a/AppImage/README.md b/AppImage/README.md index ecd8f9e..988af1c 100644 --- a/AppImage/README.md +++ b/AppImage/README.md @@ -2,40 +2,811 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next.js and React. +--- + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Technology Stack](#technology-stack) +- [Installation](#installation) +- [Authentication & Security](#authentication--security) + - [Setup Authentication](#setup-authentication) + - [Two-Factor Authentication (2FA)](#two-factor-authentication-2fa) + - [Security Best Practices for API Tokens](#security-best-practices-for-api-tokens) +- [API Documentation](#api-documentation) + - [API Authentication](#api-authentication) + - [Generating API Tokens](#generating-api-tokens) + - [Available Endpoints](#available-endpoints) +- [Integration Examples](#integration-examples) + - [Homepage Integration](#homepage-integration) + - [Home Assistant Integration](#home-assistant-integration) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Overview + +**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs. + +The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network. + + +## Screenshots + +Get a quick overview of ProxMenux Monitor's main features: + +

+ Overview Dashboard +
+ System Overview - Monitor CPU, memory, temperature, and uptime in real-time +

+ +

+ Storage Management +
+ Storage Management - Visual representation of disk usage and health +

+ +

+ Network Monitoring +
+ Network Monitoring - Real-time traffic graphs and interface statistics +

+ +

+ Virtual Machines & LXC +
+ VMs & LXC Containers - Comprehensive view with resource usage and controls +

+ +

+ Hardware Information +
+ Hardware Information - Detailed specs for CPU, GPU, and PCIe devices +

+ +

+ System Logs +
+ System Logs - Real-time monitoring with filtering and search +

+ +--- + ## Features -- **System Overview**: Real-time monitoring of CPU, memory, temperature, and active VMs/LXC containers -- **Storage Management**: Visual representation of storage distribution and disk performance metrics -- **Network Monitoring**: Network interface statistics and performance graphs -- **Virtual Machines**: Comprehensive view of VMs and LXC containers with resource usage -- **System Logs**: Real-time system log monitoring and filtering +- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime +- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data +- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage +- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls +- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks +- **System Logs**: Real-time system log monitoring with filtering and search capabilities +- **Health Monitoring**: Proactive system health checks with persistent error tracking +- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication +- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards - **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design -- **Responsive Design**: Works seamlessly on desktop and mobile devices -- **Onboarding Experience**: Interactive welcome carousel for first-time users +- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices +- **Release Notes**: Automatic notifications of new features and improvements ## Technology Stack - **Frontend**: Next.js 15, React 19, TypeScript -- **Styling**: Tailwind CSS with custom Proxmox-inspired theme +- **Styling**: Tailwind CSS v4 with custom Proxmox-inspired theme - **Charts**: Recharts for data visualization - **UI Components**: Radix UI primitives with shadcn/ui -- **Backend**: Flask server for system data collection -- **Packaging**: AppImage for easy distribution +- **Backend**: Flask (Python) server for system data collection +- **Packaging**: AppImage for easy distribution and deployment -## Onboarding Images +## Installation -To customize the onboarding experience, place your screenshot images in `public/images/onboarding/`: +**ProxMenux Monitor is integrated into [ProxMenux](https://proxmenux.com) and comes enabled by default.** No manual installation is required if you're using ProxMenux. -- `imagen1.png` - Overview section screenshot -- `imagen2.png` - Storage section screenshot -- `imagen3.png` - Network section screenshot -- `imagen4.png` - VMs & LXCs section screenshot -- `imagen5.png` - Hardware section screenshot -- `imagen6.png` - System Logs section screenshot +The monitor automatically starts when ProxMenux is installed and runs as a systemd service on your Proxmox server. -**Recommended image specifications:** -- Format: PNG or JPG -- Size: 1200x800px or similar 3:2 aspect ratio -- Quality: High-quality screenshots with representative data +### Accessing the Dashboard -The onboarding carousel will automatically show on first visit and can be dismissed or marked as "Don't show again". +You can access ProxMenux Monitor in two ways: + +1. **Direct Access**: `http://your-proxmox-ip:8008` +2. **Via Proxy** (Recommended): `https://your-domain.com/proxmenux-monitor/` + +**Note**: All API endpoints work seamlessly with both direct access and proxy configurations. When using a reverse proxy, the application automatically detects and adapts to the proxied environment. + +### Proxy Configuration + +ProxMenux Monitor includes built-in support for reverse proxy configurations. If you're using Nginx, Caddy, or Traefik, the application will automatically: + +- Detect the proxy headers (`X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`) +- Adjust API endpoints to work correctly through the proxy +- Maintain full functionality for all features including authentication and API access + +**Example Nginx configuration:** +```nginx +location /proxmenux-monitor/ { + proxy_pass http://localhost:8008/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; +} +``` + + +## Authentication & Security + +ProxMenux Monitor includes an optional authentication system to protect your dashboard with a password and two-factor authentication. + +### Setup Authentication + +On first launch, you'll be presented with three options: + +1. **Set up authentication** - Create a username and password to protect your dashboard +2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security +3. **Skip** - Continue without authentication (not recommended for production environments) + +![Authentication Setup](AppImage/public/images/docs/auth-setup.png) + +### Two-Factor Authentication (2FA) + +After setting up your password, you can enable 2FA using any TOTP authenticator app (Google Authenticator, Authy, 1Password, etc.): + +1. Navigate to **Settings > Authentication** +2. Click **Enable 2FA** +3. Scan the QR code with your authenticator app +4. Enter the 6-digit code to verify +5. Save your backup codes in a secure location + +![2FA Setup](AppImage/public/images/docs/2fa-setup.png) + +### Security Best Practices for API Tokens + +**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management. + +**Option 1: Environment Variables** + +Store your token in an environment variable: + +```bash +# Linux/macOS - Add to ~/.bashrc or ~/.zshrc +export PROXMENUX_API_TOKEN="your_actual_token_here" + +# Windows PowerShell - Add to profile +$env:PROXMENUX_API_TOKEN = "your_actual_token_here" +``` + +Then reference it in your scripts: + +```bash +# Linux/macOS +curl -H "Authorization: Bearer $PROXMENUX_API_TOKEN" \ + http://your-proxmox-ip:8008/api/system + +# Windows PowerShell +curl -H "Authorization: Bearer $env:PROXMENUX_API_TOKEN" ` + http://your-proxmox-ip:8008/api/system +``` + +**Option 2: Secrets File** + +Create a dedicated secrets file (make sure to add it to `.gitignore`): + +```bash +# Create secrets file +echo "PROXMENUX_API_TOKEN=your_actual_token_here" > ~/.proxmenux_secrets + +# Secure the file (Linux/macOS only) +chmod 600 ~/.proxmenux_secrets + +# Load in your script +source ~/.proxmenux_secrets +``` + +**Option 3: Homepage Secrets (Recommended)** + +Homepage supports secrets management. Create a `secrets.yaml` file: + +```yaml +# secrets.yaml (add to .gitignore!) +proxmenux_token: "your_actual_token_here" +``` + +Then reference it in your `services.yaml`: + +```yaml +- ProxMenux Monitor: + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/system + headers: + Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}} +``` + +**Option 4: Home Assistant Secrets** + +Home Assistant has built-in secrets support. Edit `secrets.yaml`: + +```yaml +# secrets.yaml +proxmenux_api_token: "your_actual_token_here" +``` + +Then reference it in `configuration.yaml`: + +```yaml +sensor: + - platform: rest + name: ProxMenux CPU + resource: http://proxmox.example.tld:8008/api/system + headers: + Authorization: !secret proxmenux_api_token +``` + +**Token Security Checklist:** +- ✅ Store tokens in environment variables or secrets files +- ✅ Add secrets files to `.gitignore` +- ✅ Set proper file permissions (chmod 600 on Linux/macOS) +- ✅ Rotate tokens periodically (every 3-6 months) +- ✅ Use different tokens for different integrations +- ✅ Delete tokens you no longer use +- ❌ Never commit tokens to version control +- ❌ Never share tokens in screenshots or logs +- ❌ Never hardcode tokens in configuration files + +--- + +## API Documentation + +ProxMenux Monitor provides a comprehensive RESTful API for integrating with external services like Homepage, Home Assistant, or custom dashboards. + +### API Authentication + +When authentication is enabled on ProxMenux Monitor, all API endpoints (except `/api/health` and `/api/auth/*`) require a valid JWT token in the `Authorization` header. + +### API Endpoint Base URL + +**Direct Access:** +``` +http://your-proxmox-ip:8008/api/ +``` + +**Via Proxy:** +``` +https://your-domain.com/proxmenux-monitor/api/ +``` + +**Note**: All API examples in this documentation work with both direct and proxied URLs. Simply replace the base URL with your preferred access method. + +### Generating API Tokens + +To use the API with authentication enabled, you need to generate a long-lived API token. + +#### Option 1: Generate via Web Panel (Recommended) + +The easiest way to generate an API token is through the ProxMenux Monitor web interface: + +1. Navigate to **Settings** tab in the dashboard +2. Scroll to the **API Access Tokens** section +3. Enter your password +4. If 2FA is enabled, enter your 6-digit code +5. Provide a name for the token (e.g., "Homepage Integration") +6. Click **Generate Token** +7. Copy the token immediately - it will not be shown again + +![Generate API Token](AppImage/public/images/docs/generate-api-token.png) + +The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application. + +#### Option 2: Generate via API Call + +For advanced users or automation, you can generate tokens programmatically: + +```bash +curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \ + -H "Content-Type: application/json" \ + -d '{ + "username": "your-username", + "password": "your-password", + "totp_token": "123456", + "token_name": "Homepage Integration" + }' +``` + +**Response:** +```json +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_name": "Homepage Integration", + "expires_in": "365 days", + "message": "API token generated successfully. Store this token securely, it will not be shown again." +} +``` + +**Notes:** +- If 2FA is enabled, include the `totp_token` field with your 6-digit code +- If 2FA is not enabled, omit the `totp_token` field +- The token is valid for **365 days** (1 year) +- Store the token securely - it cannot be retrieved again + +#### Option 3: Generate via cURL (without 2FA) + +```bash +# Without 2FA +curl -X POST http://your-proxmox-ip:8008/api/auth/generate-api-token \ + -H "Content-Type: application/json" \ + -d '{"username":"pedro","password":"your-password","token_name":"Homepage"}' +``` + +### Using API Tokens + +Once you have your API token, include it in the `Authorization` header of all API requests: + +```bash +curl -H "Authorization: Bearer YOUR_API_TOKEN_HERE" \ + http://your-proxmox-ip:8008/api/system +``` + +--- + +### Available Endpoints + +Below is a complete list of all API endpoints with descriptions and example responses. + +#### System & Metrics + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/system` | GET | Yes | Complete system information (CPU, memory, temperature, uptime) | +| `/api/system-info` | GET | No | Lightweight system info for header (hostname, uptime, health) | +| `/api/node/metrics` | GET | Yes | Historical metrics data (RRD) for CPU, memory, disk I/O | +| `/api/prometheus` | GET | Yes | Export metrics in Prometheus format | + +**Example `/api/system` Response:** +```json +{ + "hostname": "pve", + "cpu_usage": 15.2, + "memory_usage": 45.8, + "temperature": 42.5, + "uptime": 345600, + "kernel": "6.2.16-3-pve", + "pve_version": "8.0.3" +} +``` + +#### Storage + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/storage` | GET | Yes | Complete storage information with SMART data | +| `/api/storage/summary` | GET | Yes | Optimized storage summary (without SMART) | +| `/api/proxmox-storage` | GET | Yes | Proxmox storage pools information | +| `/api/backups` | GET | Yes | List of all backup files | + +**Example `/api/storage/summary` Response:** +```json +{ + "total_capacity": 1431894917120, + "used_space": 197414092800, + "free_space": 1234480824320, + "usage_percentage": 13.8, + "disks": [ + { + "device": "/dev/sda", + "model": "Samsung SSD 970", + "size": "476.94 GB", + "type": "SSD" + } + ] +} +``` + +#### Network + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/network` | GET | Yes | Complete network information for all interfaces | +| `/api/network/summary` | GET | Yes | Optimized network summary | +| `/api/network//metrics` | GET | Yes | Historical metrics (RRD) for specific interface | + +**Example `/api/network/summary` Response:** +```json +{ + "interfaces": [ + { + "name": "vmbr0", + "ip": "192.168.1.100", + "state": "up", + "rx_bytes": 1234567890, + "tx_bytes": 987654321 + } + ] +} +``` + +#### Virtual Machines & Containers + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/vms` | GET | Yes | List of all VMs and LXC containers | +| `/api/vms/` | GET | Yes | Detailed configuration for specific VM/LXC | +| `/api/vms//metrics` | GET | Yes | Historical metrics (RRD) for specific VM/LXC | +| `/api/vms//logs` | GET | Yes | Download real logs for specific VM/LXC | +| `/api/vms//control` | POST | Yes | Control VM/LXC (start, stop, shutdown, reboot) | +| `/api/vms//config` | PUT | Yes | Update VM/LXC configuration (description/notes) | + +**Example `/api/vms` Response:** +```json +{ + "vms": [ + { + "vmid": "100", + "name": "ubuntu-server", + "type": "qemu", + "status": "running", + "cpu": 2, + "maxcpu": 4, + "mem": 2147483648, + "maxmem": 4294967296, + "uptime": 86400 + } + ] +} +``` + +#### Hardware + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/hardware` | GET | Yes | Complete hardware information (CPU, GPU, PCIe, disks) | +| `/api/gpu//realtime` | GET | Yes | Real-time monitoring for specific GPU | + +**Example `/api/hardware` Response:** +```json +{ + "cpu": { + "model": "AMD Ryzen 9 5950X", + "cores": 16, + "threads": 32, + "frequency": "3.4 GHz" + }, + "gpus": [ + { + "slot": "0000:01:00.0", + "vendor": "NVIDIA", + "model": "GeForce RTX 3080", + "driver": "nvidia" + } + ] +} +``` + +#### Logs, Events & Notifications + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/logs` | GET | Yes | System logs (journalctl) with filters | +| `/api/logs/download` | GET | Yes | Download logs as text file | +| `/api/notifications` | GET | Yes | Proxmox notification history | +| `/api/notifications/download` | GET | Yes | Download full notification log | +| `/api/events` | GET | Yes | Recent Proxmox tasks and events | +| `/api/task-log/` | GET | Yes | Full log for specific task using UPID | + +**Example `/api/logs` Query Parameters:** +``` +/api/logs?severity=error&since=1h&search=failed +``` + +#### Health Monitoring + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/health` | GET | No | Basic health check (for external monitoring) | +| `/api/health/status` | GET | Yes | Summary of system health status | +| `/api/health/details` | GET | Yes | Detailed health check results | +| `/api/health/acknowledge` | POST | Yes | Dismiss/acknowledge health warnings | +| `/api/health/active-errors` | GET | Yes | Get active persistent errors | + +#### ProxMenux Optimizations + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/proxmenux/installed-tools` | GET | Yes | List of installed ProxMenux optimizations | + +#### Authentication + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/api/auth/status` | GET | No | Current authentication status | +| `/api/auth/login` | POST | No | Authenticate and receive JWT token | +| `/api/auth/generate-api-token` | POST | No | Generate long-lived API token (365 days) | +| `/api/auth/setup` | POST | No | Initial setup of username/password | +| `/api/auth/enable` | POST | No | Enable authentication | +| `/api/auth/disable` | POST | Yes | Disable authentication | +| `/api/auth/change-password` | POST | No | Change password | +| `/api/auth/totp/setup` | POST | Yes | Initialize 2FA setup | +| `/api/auth/totp/enable` | POST | Yes | Enable 2FA after verification | +| `/api/auth/totp/disable` | POST | Yes | Disable 2FA | + +--- + +## Integration Examples + +### Homepage Integration + +[Homepage](https://gethomepage.dev/) is a modern, fully static, fast, secure fully proxied, highly customizable application dashboard. + +#### Basic Configuration (No Authentication) + +```yaml +- ProxMenux Monitor: + href: http://proxmox.example.tld:8008/ + icon: lucide:flask-round + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/system + refreshInterval: 10000 + mappings: + - field: uptime + label: Uptime + icon: lucide:clock-4 + format: text + - field: cpu_usage + label: CPU + icon: lucide:cpu + format: percent + - field: memory_usage + label: RAM + icon: lucide:memory-stick + format: percent + - field: temperature + label: Temp + icon: lucide:thermometer-sun + format: number + suffix: °C +``` + +#### With Authentication Enabled (Using Secrets) + +First, generate an API token via the web interface (Settings > API Access Tokens) or via API. + +Then, store your token securely in Homepage's `secrets.yaml`: + +```yaml +# secrets.yaml (add to .gitignore!) +proxmenux_token: "your_actual_api_token_here" +``` + +Finally, reference the secret in your `services.yaml`: + +```yaml +- ProxMenux Monitor: + href: http://proxmox.example.tld:8008/ + icon: lucide:flask-round + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/system + headers: + Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}} + refreshInterval: 10000 + mappings: + - field: uptime + label: Uptime + icon: lucide:clock-4 + format: text + - field: cpu_usage + label: CPU + icon: lucide:cpu + format: percent + - field: memory_usage + label: RAM + icon: lucide:memory-stick + format: percent + - field: temperature + label: Temp + icon: lucide:thermometer-sun + format: number + suffix: °C +``` + +#### Advanced Multi-Widget Configuration + +```yaml +# Store token in secrets.yaml +# proxmenux_token: "your_actual_api_token_here" + +- ProxMenux System: + href: http://proxmox.example.tld:8008/ + icon: lucide:server + description: Proxmox VE Host + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/system + headers: + Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}} + refreshInterval: 5000 + mappings: + - field: cpu_usage + label: CPU + icon: lucide:cpu + format: percent + - field: memory_usage + label: RAM + icon: lucide:memory-stick + format: percent + - field: temperature + label: Temp + icon: lucide:thermometer-sun + format: number + suffix: °C + +- ProxMenux Storage: + href: http://proxmox.example.tld:8008/#/storage + icon: lucide:hard-drive + description: Storage Overview + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/storage/summary + headers: + Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}} + refreshInterval: 30000 + mappings: + - field: usage_percentage + label: Used + icon: lucide:database + format: percent + - field: used_space + label: Space + icon: lucide:folder + format: bytes + +- ProxMenux Network: + href: http://proxmox.example.tld:8008/#/network + icon: lucide:network + description: Network Stats + widget: + type: customapi + url: http://proxmox.example.tld:8008/api/network/summary + headers: + Authorization: Bearer {{HOMEPAGE_VAR_PROXMENUX_TOKEN}} + refreshInterval: 5000 + mappings: + - field: interfaces[0].rx_bytes + label: Received + icon: lucide:download + format: bytes + - field: interfaces[0].tx_bytes + label: Sent + icon: lucide:upload + format: bytes +``` + +![Homepage Integration Example](AppImage/public/images/docs/homepage-integration.png) + +### Home Assistant Integration + +[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform. + +#### Store Token Securely + +First, add your API token to Home Assistant's `secrets.yaml`: + +```yaml +# secrets.yaml +proxmenux_api_token: "Bearer your_actual_api_token_here" +``` + +**Note**: Include "Bearer " prefix in the secrets file for Home Assistant. + +#### Configuration.yaml + +```yaml +# ProxMenux Monitor Sensors +sensor: + - platform: rest + name: ProxMenux CPU + resource: http://proxmox.example.tld:8008/api/system + headers: + Authorization: !secret proxmenux_api_token + value_template: "{{ value_json.cpu_usage }}" + unit_of_measurement: "%" + scan_interval: 30 + + - platform: rest + name: ProxMenux Memory + resource: http://proxmox.example.tld:8008/api/system + headers: + Authorization: !secret proxmenux_api_token + value_template: "{{ value_json.memory_usage }}" + unit_of_measurement: "%" + scan_interval: 30 + + - platform: rest + name: ProxMenux Temperature + resource: http://proxmox.example.tld:8008/api/system + headers: + Authorization: !secret proxmenux_api_token + value_template: "{{ value_json.temperature }}" + unit_of_measurement: "°C" + device_class: temperature + scan_interval: 30 + + - platform: rest + name: ProxMenux Uptime + resource: http://proxmox.example.tld:8008/api/system + headers: + Authorization: !secret proxmenux_api_token + value_template: > + {% set uptime_seconds = value_json.uptime | int %} + {% set days = (uptime_seconds / 86400) | int %} + {% set hours = ((uptime_seconds % 86400) / 3600) | int %} + {% set minutes = ((uptime_seconds % 3600) / 60) | int %} + {{ days }}d {{ hours }}h {{ minutes }}m + scan_interval: 60 +``` + +#### Lovelace Card Example + +```yaml +type: entities +title: Proxmox Monitor +entities: + - entity: sensor.proxmenux_cpu + name: CPU Usage + icon: mdi:cpu-64-bit + - entity: sensor.proxmenux_memory + name: Memory Usage + icon: mdi:memory + - entity: sensor.proxmenux_temperature + name: Temperature + icon: mdi:thermometer + - entity: sensor.proxmenux_uptime + name: Uptime + icon: mdi:clock-outline +``` + +![Home Assistant Integration Example](AppImage/public/images/docs/homeassistant-integration.png) + +--- + +## Contributing + +Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests. + +### Development Setup + +1. Clone the repository +2. Install dependencies: `npm install` +3. Run development server: `npm run dev` +4. Build AppImage: `./build_appimage.sh` + +--- + +## License + +This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**. + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Adapt — remix, transform, and build upon the material + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made +- NonCommercial — You may not use the material for commercial purposes + +For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/). + +--- + +## Support + +For support, feature requests, or bug reports, please visit: +- GitHub Issues: [github.com/your-repo/issues](https://github.com/your-repo/issues) +- Documentation: [github.com/your-repo/wiki](https://github.com/your-repo/wiki) + +--- + +**ProxMenux Monitor** - Made with ❤️ for the Proxmox community diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index 672cf11..e2915d4 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -61,8 +61,13 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { throw new Error(data.error || "Failed to skip authentication") } + if (data.auth_declined) { + console.log("[v0] Authentication skipped successfully - APIs should be accessible without token") + } + console.log("[v0] Authentication skipped successfully") localStorage.setItem("proxmenux-auth-declined", "true") + localStorage.removeItem("proxmenux-auth-token") // Remove any old token setOpen(false) onComplete() } catch (err) { diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 8bb8e9d..4c92378 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -20,8 +20,14 @@ import { } from "lucide-react" import useSWR from "swr" import { useState, useEffect } from "react" -import { type HardwareData, type GPU, type PCIDevice, type StorageDevice, fetcher } from "../types/hardware" -import { API_PORT } from "@/lib/api-config" +import { + type HardwareData, + type GPU, + type PCIDevice, + type StorageDevice, + fetcher as swrFetcher, +} from "../types/hardware" +import { fetchApi } from "@/lib/api-config" const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -169,7 +175,7 @@ export default function Hardware() { data: staticHardwareData, error: staticError, isLoading: staticLoading, - } = useSWR("/api/hardware", fetcher, { + } = useSWR("/api/hardware", swrFetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, refreshInterval: 0, // No auto-refresh for static data @@ -180,7 +186,7 @@ export default function Hardware() { data: dynamicHardwareData, error: dynamicError, isLoading: dynamicLoading, - } = useSWR("/api/hardware", fetcher, { + } = useSWR("/api/hardware", swrFetcher, { refreshInterval: 7000, }) @@ -231,6 +237,21 @@ export default function Hardware() { const [selectedNetwork, setSelectedNetwork] = useState(null) const [selectedUPS, setSelectedUPS] = useState(null) + const fetcher = async (url: string) => { + const data = await fetchApi(url) + return data + } + + const { + data: hardwareDataSWR, + error: swrError, + isLoading: swrLoading, + mutate, + } = useSWR("/api/hardware", fetcher, { + refreshInterval: 30000, + revalidateOnFocus: false, + }) + useEffect(() => { if (!selectedGPU) return @@ -243,30 +264,10 @@ export default function Hardware() { const fetchRealtimeData = async () => { try { - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" - - const apiUrl = isStandardPort - ? `/api/gpu/${fullSlot}/realtime` - : `${protocol}//${hostname}:${API_PORT}/api/gpu/${fullSlot}/realtime` - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - signal: abortController.signal, - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() + const data = await fetchApi(`/api/gpu/${fullSlot}/realtime`) setRealtimeGPUData(data) setDetailsLoading(false) } catch (error) { - // Only log non-abort errors if (error instanceof Error && error.name !== "AbortError") { console.error("[v0] Error fetching GPU realtime data:", error) } @@ -275,10 +276,7 @@ export default function Hardware() { } } - // Initial fetch fetchRealtimeData() - - // Poll every 3 seconds const interval = setInterval(fetchRealtimeData, 3000) return () => { @@ -294,14 +292,14 @@ export default function Hardware() { } const findPCIDeviceForGPU = (gpu: GPU): PCIDevice | null => { - if (!hardwareData?.pci_devices || !gpu.slot) return null + if (!hardwareDataSWR?.pci_devices || !gpu.slot) return null // Try to find exact match first (e.g., "00:02.0") - let pciDevice = hardwareData.pci_devices.find((d) => d.slot === gpu.slot) + let pciDevice = hardwareDataSWR.pci_devices.find((d) => d.slot === gpu.slot) // If not found, try to match by partial slot (e.g., "00" matches "00:02.0") if (!pciDevice && gpu.slot.length <= 2) { - pciDevice = hardwareData.pci_devices.find( + pciDevice = hardwareDataSWR.pci_devices.find( (d) => d.slot.startsWith(gpu.slot + ":") && (d.type.toLowerCase().includes("vga") || @@ -320,7 +318,7 @@ export default function Hardware() { return realtimeGPUData.has_monitoring_tool === true } - if (isLoading) { + if (swrLoading) { return (
@@ -333,7 +331,7 @@ export default function Hardware() { return (
{/* System Information - CPU & Motherboard */} - {(hardwareData?.cpu || hardwareData?.motherboard) && ( + {(hardwareDataSWR?.cpu || hardwareDataSWR?.motherboard) && (
@@ -342,44 +340,44 @@ export default function Hardware() {
{/* CPU Info */} - {hardwareData?.cpu && Object.keys(hardwareData.cpu).length > 0 && ( + {hardwareDataSWR?.cpu && Object.keys(hardwareDataSWR.cpu).length > 0 && (

CPU

- {hardwareData.cpu.model && ( + {hardwareDataSWR.cpu.model && (
Model - {hardwareData.cpu.model} + {hardwareDataSWR.cpu.model}
)} - {hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && ( + {hardwareDataSWR.cpu.cores_per_socket && hardwareDataSWR.cpu.sockets && (
Cores - {hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "} - {hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket} cores + {hardwareDataSWR.cpu.sockets} × {hardwareDataSWR.cpu.cores_per_socket} ={" "} + {hardwareDataSWR.cpu.sockets * hardwareDataSWR.cpu.cores_per_socket} cores
)} - {hardwareData.cpu.total_threads && ( + {hardwareDataSWR.cpu.total_threads && (
Threads - {hardwareData.cpu.total_threads} + {hardwareDataSWR.cpu.total_threads}
)} - {hardwareData.cpu.l3_cache && ( + {hardwareDataSWR.cpu.l3_cache && (
L3 Cache - {hardwareData.cpu.l3_cache} + {hardwareDataSWR.cpu.l3_cache}
)} - {hardwareData.cpu.virtualization && ( + {hardwareDataSWR.cpu.virtualization && (
Virtualization - {hardwareData.cpu.virtualization} + {hardwareDataSWR.cpu.virtualization}
)}
@@ -387,41 +385,41 @@ export default function Hardware() { )} {/* Motherboard Info */} - {hardwareData?.motherboard && Object.keys(hardwareData.motherboard).length > 0 && ( + {hardwareDataSWR?.motherboard && Object.keys(hardwareDataSWR.motherboard).length > 0 && (

Motherboard

- {hardwareData.motherboard.manufacturer && ( + {hardwareDataSWR.motherboard.manufacturer && (
Manufacturer - {hardwareData.motherboard.manufacturer} + {hardwareDataSWR.motherboard.manufacturer}
)} - {hardwareData.motherboard.model && ( + {hardwareDataSWR.motherboard.model && (
Model - {hardwareData.motherboard.model} + {hardwareDataSWR.motherboard.model}
)} - {hardwareData.motherboard.bios?.vendor && ( + {hardwareDataSWR.motherboard.bios?.vendor && (
BIOS - {hardwareData.motherboard.bios.vendor} + {hardwareDataSWR.motherboard.bios.vendor}
)} - {hardwareData.motherboard.bios?.version && ( + {hardwareDataSWR.motherboard.bios?.version && (
Version - {hardwareData.motherboard.bios.version} + {hardwareDataSWR.motherboard.bios.version}
)} - {hardwareData.motherboard.bios?.date && ( + {hardwareDataSWR.motherboard.bios?.date && (
Date - {hardwareData.motherboard.bios.date} + {hardwareDataSWR.motherboard.bios.date}
)}
@@ -432,18 +430,18 @@ export default function Hardware() { )} {/* Memory Modules */} - {hardwareData?.memory_modules && hardwareData.memory_modules.length > 0 && ( + {hardwareDataSWR?.memory_modules && hardwareDataSWR.memory_modules.length > 0 && (

Memory Modules

- {hardwareData.memory_modules.length} installed + {hardwareDataSWR.memory_modules.length} installed
- {hardwareData.memory_modules.map((module, index) => ( + {hardwareDataSWR.memory_modules.map((module, index) => (
{module.slot}
@@ -479,29 +477,29 @@ export default function Hardware() { )} {/* Thermal Monitoring */} - {hardwareData?.temperatures && hardwareData.temperatures.length > 0 && ( + {hardwareDataSWR?.temperatures && hardwareDataSWR.temperatures.length > 0 && (

Thermal Monitoring

- {hardwareData.temperatures.length} sensors + {hardwareDataSWR.temperatures.length} sensors
{/* CPU Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).CPU.length > 0 && ( + {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length > 0 && (

CPU

- {groupAndSortTemperatures(hardwareData.temperatures).CPU.length} + {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length}
- {groupAndSortTemperatures(hardwareData.temperatures).CPU.map((temp, index) => { + {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -532,21 +530,21 @@ export default function Hardware() { )} {/* GPU Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 0 && ( + {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length > 0 && (
1 ? "md:col-span-2" : ""} + className={groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length > 1 ? "md:col-span-2" : ""} >

GPU

- {groupAndSortTemperatures(hardwareData.temperatures).GPU.length} + {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareData.temperatures).GPU.map((temp, index) => { + {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -577,21 +575,23 @@ export default function Hardware() { )} {/* NVME Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && ( + {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length > 0 && (
1 ? "md:col-span-2" : ""} + className={ + groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length > 1 ? "md:col-span-2" : "" + } >

NVME

- {groupAndSortTemperatures(hardwareData.temperatures).NVME.length} + {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => { + {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -622,21 +622,21 @@ export default function Hardware() { )} {/* PCI Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && ( + {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length > 0 && (
1 ? "md:col-span-2" : ""} + className={groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length > 1 ? "md:col-span-2" : ""} >

PCI

- {groupAndSortTemperatures(hardwareData.temperatures).PCI.length} + {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareData.temperatures).PCI.map((temp, index) => { + {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -667,21 +667,23 @@ export default function Hardware() { )} {/* OTHER Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 0 && ( + {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length > 0 && (
1 ? "md:col-span-2" : ""} + className={ + groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length > 1 ? "md:col-span-2" : "" + } >

OTHER

- {groupAndSortTemperatures(hardwareData.temperatures).OTHER.length} + {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareData.temperatures).OTHER.map((temp, index) => { + {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -715,18 +717,18 @@ export default function Hardware() { )} {/* GPU Information - Enhanced with on-demand data fetching */} - {hardwareData?.gpus && hardwareData.gpus.length > 0 && ( + {hardwareDataSWR?.gpus && hardwareDataSWR.gpus.length > 0 && (

Graphics Cards

- {hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""} + {hardwareDataSWR.gpus.length} GPU{hardwareDataSWR.gpus.length > 1 ? "s" : ""}
- {hardwareData.gpus.map((gpu, index) => { + {hardwareDataSWR.gpus.map((gpu, index) => { const pciDevice = findPCIDeviceForGPU(gpu) const fullSlot = pciDevice?.slot || gpu.slot @@ -1104,18 +1106,18 @@ export default function Hardware() { {/* PCI Devices - Changed to modal */} - {hardwareData?.pci_devices && hardwareData.pci_devices.length > 0 && ( + {hardwareDataSWR?.pci_devices && hardwareDataSWR.pci_devices.length > 0 && (

PCI Devices

- {hardwareData.pci_devices.length} devices + {hardwareDataSWR.pci_devices.length} devices
- {hardwareData.pci_devices.map((device, index) => ( + {hardwareDataSWR.pci_devices.map((device, index) => (
setSelectedPCIDevice(device)} @@ -1190,7 +1192,7 @@ export default function Hardware() { {/* Power Consumption */} - {hardwareData?.power_meter && ( + {hardwareDataSWR?.power_meter && (
@@ -1200,13 +1202,13 @@ export default function Hardware() {
-

{hardwareData.power_meter.name}

- {hardwareData.power_meter.adapter && ( -

{hardwareData.power_meter.adapter}

+

{hardwareDataSWR.power_meter.name}

+ {hardwareDataSWR.power_meter.adapter && ( +

{hardwareDataSWR.power_meter.adapter}

)}
-

{hardwareData.power_meter.watts.toFixed(1)} W

+

{hardwareDataSWR.power_meter.watts.toFixed(1)} W

Current Draw

@@ -1215,18 +1217,18 @@ export default function Hardware() { )} {/* Power Supplies */} - {hardwareData?.power_supplies && hardwareData.power_supplies.length > 0 && ( + {hardwareDataSWR?.power_supplies && hardwareDataSWR.power_supplies.length > 0 && (

Power Supplies

- {hardwareData.power_supplies.length} PSUs + {hardwareDataSWR.power_supplies.length} PSUs
- {hardwareData.power_supplies.map((psu, index) => ( + {hardwareDataSWR.power_supplies.map((psu, index) => (
{psu.name} @@ -1243,18 +1245,18 @@ export default function Hardware() { )} {/* Fans */} - {hardwareData?.fans && hardwareData.fans.length > 0 && ( + {hardwareDataSWR?.fans && hardwareDataSWR.fans.length > 0 && (

System Fans

- {hardwareData.fans.length} fans + {hardwareDataSWR.fans.length} fans
- {hardwareData.fans.map((fan, index) => { + {hardwareDataSWR.fans.map((fan, index) => { const isPercentage = fan.unit === "percent" || fan.unit === "%" const percentage = isPercentage ? fan.speed : Math.min((fan.speed / 5000) * 100, 100) @@ -1278,18 +1280,18 @@ export default function Hardware() { )} {/* UPS */} - {hardwareData?.ups && Array.isArray(hardwareData.ups) && hardwareData.ups.length > 0 && ( + {hardwareDataSWR?.ups && Array.isArray(hardwareDataSWR.ups) && hardwareDataSWR.ups.length > 0 && (

UPS Status

- {hardwareData.ups.length} UPS + {hardwareDataSWR.ups.length} UPS
- {hardwareData.ups.map((ups: any, index: number) => { + {hardwareDataSWR.ups.map((ups: any, index: number) => { const batteryCharge = ups.battery_charge_raw || Number.parseFloat(ups.battery_charge?.replace("%", "") || "0") const loadPercent = ups.load_percent_raw || Number.parseFloat(ups.load_percent?.replace("%", "") || "0") @@ -1560,19 +1562,19 @@ export default function Hardware() { {/* Network Summary - Clickable */} - {hardwareData?.pci_devices && - hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && ( + {hardwareDataSWR?.pci_devices && + hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && (

Network Summary

- {hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces + {hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces
- {hardwareData.pci_devices + {hardwareDataSWR.pci_devices .filter((d) => d.type.toLowerCase().includes("network")) .map((device, index) => (
{/* Storage Summary - Clickable */} - {hardwareData?.storage_devices && hardwareData.storage_devices.length > 0 && ( + {hardwareDataSWR?.storage_devices && hardwareDataSWR.storage_devices.length > 0 && (

Storage Summary

{ - hardwareData.storage_devices.filter( + hardwareDataSWR.storage_devices.filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), ).length @@ -1669,7 +1671,7 @@ export default function Hardware() {
- {hardwareData.storage_devices + {hardwareDataSWR.storage_devices .filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), ) diff --git a/AppImage/components/metrics-dialog.tsx b/AppImage/components/metrics-dialog.tsx index c6f521a..ce201a6 100644 --- a/AppImage/components/metrics-dialog.tsx +++ b/AppImage/components/metrics-dialog.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ArrowLeft, Loader2 } from "lucide-react" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" -import { API_PORT } from "@/lib/api-config" +import { fetchApi } from "@/lib/api-config" interface MetricsViewProps { vmid: number @@ -119,21 +119,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) setError(null) try { - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" - - const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}` - - const apiUrl = `${baseUrl}/api/vms/${vmid}/metrics?timeframe=${timeframe}` - - const response = await fetch(apiUrl) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || "Failed to fetch metrics") - } - - const result = await response.json() + const result = await fetchApi(`/api/vms/${vmid}/metrics?timeframe=${timeframe}`) const transformedData = result.data.map((item: any) => { const date = new Date(item.time * 1000) diff --git a/AppImage/components/network-card.tsx b/AppImage/components/network-card.tsx index 3c00c28..06f8f95 100644 --- a/AppImage/components/network-card.tsx +++ b/AppImage/components/network-card.tsx @@ -4,6 +4,7 @@ import { Card, CardContent } from "./ui/card" import { Badge } from "./ui/badge" import { Wifi, Zap } from "lucide-react" import { useState, useEffect } from "react" +import { fetchApi } from "../lib/api-config" interface NetworkCardProps { interface_: { @@ -94,26 +95,12 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps useEffect(() => { const fetchTrafficData = async () => { try { - const response = await fetch(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - signal: AbortSignal.timeout(5000), - }) + const data = await fetchApi(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`) - if (!response.ok) { - throw new Error(`Failed to fetch traffic data: ${response.status}`) - } - - const data = await response.json() - - // Calculate totals from the data points if (data.data && data.data.length > 0) { const lastPoint = data.data[data.data.length - 1] const firstPoint = data.data[0] - // Calculate the difference between last and first data points const receivedGB = Math.max(0, (lastPoint.netin || 0) - (firstPoint.netin || 0)) const sentGB = Math.max(0, (lastPoint.netout || 0) - (firstPoint.netout || 0)) @@ -124,16 +111,13 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps } } catch (error) { console.error("[v0] Failed to fetch traffic data for card:", error) - // Keep showing 0 values on error setTrafficData({ received: 0, sent: 0 }) } } - // Only fetch if interface is up and not a VM if (interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm") { fetchTrafficData() - // Refresh every 60 seconds const interval = setInterval(fetchTrafficData, 60000) return () => clearInterval(interval) } diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx index 6b6de14..724bd80 100644 --- a/AppImage/components/network-metrics.tsx +++ b/AppImage/components/network-metrics.tsx @@ -8,6 +8,7 @@ import { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react" import useSWR from "swr" import { NetworkTrafficChart } from "./network-traffic-chart" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { fetchApi } from "../lib/api-config" interface NetworkData { interfaces: NetworkInterface[] @@ -128,19 +129,7 @@ const formatSpeed = (speed: number): string => { } const fetcher = async (url: string): Promise => { - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - signal: AbortSignal.timeout(5000), - }) - - if (!response.ok) { - throw new Error(`Flask server responded with status: ${response.status}`) - } - - return response.json() + return fetchApi(url) } export function NetworkMetrics() { diff --git a/AppImage/components/network-traffic-chart.tsx b/AppImage/components/network-traffic-chart.tsx index a093c41..7d9eac0 100644 --- a/AppImage/components/network-traffic-chart.tsx +++ b/AppImage/components/network-traffic-chart.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" import { Loader2 } from "lucide-react" -import { API_PORT } from "@/lib/api-config" +import { fetchApi } from "@/lib/api-config" interface NetworkMetricsData { time: string @@ -76,24 +76,13 @@ export function NetworkTrafficChart({ setError(null) try { - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" + const apiPath = interfaceName + ? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}` + : `/api/node/metrics?timeframe=${timeframe}` - const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}` + console.log("[v0] Fetching network metrics from:", apiPath) - const apiUrl = interfaceName - ? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}` - : `${baseUrl}/api/node/metrics?timeframe=${timeframe}` - - console.log("[v0] Fetching network metrics from:", apiUrl) - - const response = await fetch(apiUrl) - - if (!response.ok) { - throw new Error(`Failed to fetch network metrics: ${response.status}`) - } - - const result = await response.json() + const result = await fetchApi(apiPath) if (!result.data || !Array.isArray(result.data)) { throw new Error("Invalid data format received from server") diff --git a/AppImage/components/node-metrics-charts.tsx b/AppImage/components/node-metrics-charts.tsx index 40be442..575b973 100644 --- a/AppImage/components/node-metrics-charts.tsx +++ b/AppImage/components/node-metrics-charts.tsx @@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" import { Loader2, TrendingUp, MemoryStick } from "lucide-react" import { useIsMobile } from "../hooks/use-mobile" -import { API_PORT } from "@/lib/api-config" +import { fetchApi } from "@/lib/api-config" const TIMEFRAME_OPTIONS = [ { value: "hour", label: "1 Hour" }, @@ -89,27 +89,8 @@ export function NodeMetricsCharts() { setError(null) try { - const { protocol, hostname, port } = window.location - const isStandardPort = port === "" || port === "80" || port === "443" + const result = await fetchApi(`/api/node/metrics?timeframe=${timeframe}`) - const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}` - - const apiUrl = `${baseUrl}/api/node/metrics?timeframe=${timeframe}` - - console.log("[v0] Fetching node metrics from:", apiUrl) - - const response = await fetch(apiUrl) - - console.log("[v0] Response status:", response.status) - console.log("[v0] Response ok:", response.ok) - - if (!response.ok) { - const errorText = await response.text() - console.log("[v0] Error response text:", errorText) - throw new Error(`Failed to fetch node metrics: ${response.status}`) - } - - const result = await response.json() console.log("[v0] Node metrics result:", result) console.log("[v0] Result keys:", Object.keys(result)) console.log("[v0] Data array length:", result.data?.length || 0) diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 4737717..0ab1c83 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -14,7 +14,7 @@ import { Settings } from "./settings" import { OnboardingCarousel } from "./onboarding-carousel" import { HealthStatusModal } from "./health-status-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" -import { getApiUrl } from "../lib/api-config" +import { getApiUrl, fetchApi } from "../lib/api-config" import { RefreshCw, AlertTriangle, @@ -80,22 +80,8 @@ export function ProxmoxDashboard() { const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck() const fetchSystemData = useCallback(async () => { - const apiUrl = getApiUrl("/api/system-info") - try { - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - throw new Error(`Server responded with status: ${response.status}`) - } - - const data: FlaskSystemInfo = await response.json() + const data: FlaskSystemInfo = await fetchApi("/api/system-info") const uptimeValue = data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A" diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index bbc7cb1..81b6fe8 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -5,9 +5,23 @@ import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" -import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package } from "lucide-react" +import { + Shield, + Lock, + User, + AlertCircle, + CheckCircle, + Info, + LogOut, + Wrench, + Package, + Key, + Copy, + Eye, + EyeOff, +} from "lucide-react" import { APP_VERSION } from "./release-notes-modal" -import { getApiUrl } from "../lib/api-config" +import { getApiUrl, fetchApi } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" interface ProxMenuxTool { @@ -45,6 +59,15 @@ export function Settings() { [APP_VERSION]: true, // Current version expanded by default }) + // API Token state management + const [showApiTokenSection, setShowApiTokenSection] = useState(false) + const [apiToken, setApiToken] = useState("") + const [apiTokenVisible, setApiTokenVisible] = useState(false) + const [tokenPassword, setTokenPassword] = useState("") + const [tokenTotpCode, setTokenTotpCode] = useState("") + const [generatingToken, setGeneratingToken] = useState(false) + const [tokenCopied, setTokenCopied] = useState(false) + useEffect(() => { checkAuthStatus() loadProxmenuxTools() @@ -278,6 +301,59 @@ export function Settings() { window.location.reload() } + const handleGenerateApiToken = async () => { + setError("") + setSuccess("") + + if (!tokenPassword) { + setError("Please enter your password") + return + } + + if (totpEnabled && !tokenTotpCode) { + setError("Please enter your 2FA code") + return + } + + setGeneratingToken(true) + + try { + const data = await fetchApi("/api/auth/generate-api-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + password: tokenPassword, + totp_token: totpEnabled ? tokenTotpCode : undefined, + }), + }) + + if (!data.success) { + setError(data.message || data.error || "Failed to generate API token") + return + } + + if (!data.token) { + setError("No token received from server") + return + } + + setApiToken(data.token) + setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.") + setTokenPassword("") + setTokenTotpCode("") + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.") + } finally { + setGeneratingToken(false) + } + } + + const copyApiToken = () => { + navigator.clipboard.writeText(apiToken) + setTokenCopied(true) + setTimeout(() => setTokenCopied(false), 2000) + } + const toggleVersion = (version: string) => { setExpandedVersions((prev) => ({ ...prev, @@ -502,14 +578,23 @@ export function Settings() { )} {!totpEnabled && ( - +
+
+ +
+

Two-Factor Authentication (2FA)

+

+ Add an extra layer of security by requiring a code from your authenticator app in addition to + your password. +

+
+
+ + +
)} {totpEnabled && ( @@ -577,6 +662,199 @@ export function Settings() { + {/* API Access Tokens */} + {authEnabled && ( + + +
+ + API Access Tokens +
+ + Generate long-lived API tokens for external integrations like Homepage and Home Assistant + +
+ + {error && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{success}

+
+ )} + +
+
+ +
+

About API Tokens

+
    +
  • Tokens are valid for 1 year
  • +
  • Use them to access APIs from external services
  • +
  • Include in Authorization header: Bearer YOUR_TOKEN
  • +
  • See README.md for complete integration examples
  • +
+
+
+
+ + {!showApiTokenSection && !apiToken && ( + + )} + + {showApiTokenSection && !apiToken && ( +
+

Generate API Token

+

+ Enter your credentials to generate a new long-lived API token +

+ +
+ +
+ + setTokenPassword(e.target.value)} + className="pl-10" + disabled={generatingToken} + /> +
+
+ + {totpEnabled && ( +
+ +
+ + setTokenTotpCode(e.target.value)} + className="pl-10" + maxLength={6} + disabled={generatingToken} + /> +
+
+ )} + +
+ + +
+
+ )} + + {apiToken && ( +
+
+ +

Your API Token

+
+ +
+ +
+

+ ⚠️ Important: Save this token now! +

+

+ You won't be able to see it again. Store it securely. +

+
+
+ +
+ +
+ +
+ + +
+
+ {tokenCopied && ( +

+ + Copied to clipboard! +

+ )} +
+ +
+

How to use this token:

+
+

# Add to request headers:

+

Authorization: Bearer YOUR_TOKEN_HERE

+
+

+ See the README documentation for complete integration examples with Homepage and Home Assistant. +

+
+ + +
+ )} +
+
+ )} + {/* ProxMenux Optimizations */} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index f37192d..8c3724b 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -6,7 +6,7 @@ import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Ther import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { getApiUrl } from "../lib/api-config" +import { fetchApi } from "../lib/api-config" interface DiskInfo { name: string @@ -94,14 +94,11 @@ export function StorageOverview() { const fetchStorageData = async () => { try { - const [storageResponse, proxmoxResponse] = await Promise.all([ - fetch(getApiUrl("/api/storage")), - fetch(getApiUrl("/api/proxmox-storage")), + const [data, proxmoxData] = await Promise.all([ + fetchApi("/api/storage"), + fetchApi("/api/proxmox-storage"), ]) - const data = await storageResponse.json() - const proxmoxData = await proxmoxResponse.json() - setStorageData(data) setProxmoxStorage(proxmoxData) } catch (error) { diff --git a/AppImage/components/system-logs.tsx b/AppImage/components/system-logs.tsx index 0ef6091..ecc4ce7 100644 --- a/AppImage/components/system-logs.tsx +++ b/AppImage/components/system-logs.tsx @@ -28,7 +28,7 @@ import { Terminal, } from "lucide-react" import { useState, useEffect, useMemo } from "react" -import { API_PORT } from "@/lib/api-config" +import { API_PORT, fetchApi } from "@/lib/api-config" interface Log { timestamp: string @@ -135,6 +135,10 @@ export function SystemLogs() { return `${protocol}//${hostname}:${API_PORT}${endpoint}` } } + // This part might not be strictly necessary if only running client-side, but good for SSR safety + // In a real SSR scenario, you'd need to handle API_PORT differently + const protocol = typeof window !== "undefined" ? window.location.protocol : "http:" // Defaulting to http for SSR safety + const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost" // Defaulting to localhost for SSR safety return `${protocol}//${hostname}:${API_PORT}${endpoint}` } @@ -194,27 +198,15 @@ export function SystemLogs() { const [logsRes, backupsRes, eventsRes, notificationsRes] = await Promise.all([ fetchSystemLogs(), - fetch(getApiUrl("/api/backups")), - fetch(getApiUrl("/api/events?limit=50")), - fetch(getApiUrl("/api/notifications")), + fetchApi("/api/backups"), + fetchApi("/api/events?limit=50"), + fetchApi("/api/notifications"), ]) setLogs(logsRes) - - if (backupsRes.ok) { - const backupsData = await backupsRes.json() - setBackups(backupsData.backups || []) - } - - if (eventsRes.ok) { - const eventsData = await eventsRes.json() - setEvents(eventsData.events || []) - } - - if (notificationsRes.ok) { - const notificationsData = await notificationsRes.json() - setNotifications(notificationsData.notifications || []) - } + setBackups(backupsRes.backups || []) + setEvents(eventsRes.events || []) + setNotifications(notificationsRes.notifications || []) } catch (err) { console.error("[v0] Error fetching system logs data:", err) setError("Failed to connect to server") @@ -225,7 +217,7 @@ export function SystemLogs() { const fetchSystemLogs = async (): Promise => { try { - let apiUrl = getApiUrl("/api/logs") + let apiUrl = "/api/logs" const params = new URLSearchParams() // CHANGE: Always add since_days parameter (no more "now" option) @@ -258,22 +250,7 @@ export function SystemLogs() { } console.log("[v0] Making fetch request to:", apiUrl) - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - signal: AbortSignal.timeout(30000), // 30 second timeout - }) - - console.log("[v0] Response status:", response.status, "OK:", response.ok) - - if (!response.ok) { - throw new Error(`Flask server responded with status: ${response.status}`) - } - - const data = await response.json() + const data = await fetchApi(apiUrl) console.log("[v0] Received logs data, count:", data.logs?.length || 0) const logsArray = Array.isArray(data) ? data : data.logs || [] @@ -364,37 +341,33 @@ export function SystemLogs() { if (upid) { // Try to fetch the complete task log from Proxmox try { - const response = await fetch(getApiUrl(`/api/task-log/${encodeURIComponent(upid)}`)) + const taskLog = await fetchApi(`/api/task-log/${encodeURIComponent(upid)}`, {}, "text") - if (response.ok) { - const taskLog = await response.text() + // Download the complete task log + const blob = new Blob( + [ + `Proxmox Task Log\n`, + `================\n\n`, + `UPID: ${upid}\n`, + `Timestamp: ${notification.timestamp}\n`, + `Service: ${notification.service}\n`, + `Source: ${notification.source}\n\n`, + `Complete Task Log:\n`, + `${"-".repeat(80)}\n`, + `${taskLog}\n`, + ], + { type: "text/plain" }, + ) - // Download the complete task log - const blob = new Blob( - [ - `Proxmox Task Log\n`, - `================\n\n`, - `UPID: ${upid}\n`, - `Timestamp: ${notification.timestamp}\n`, - `Service: ${notification.service}\n`, - `Source: ${notification.source}\n\n`, - `Complete Task Log:\n`, - `${"-".repeat(80)}\n`, - `${taskLog}\n`, - ], - { type: "text/plain" }, - ) - - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `task_log_${upid.replace(/:/g, "_")}_${notification.timestamp.replace(/[:\s]/g, "_")}.txt` - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - return - } + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `task_log_${upid.replace(/:/g, "_")}_${notification.timestamp.replace(/[:\s]/g, "_")}.txt` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + return } catch (error) { console.error("[v0] Failed to fetch task log from Proxmox:", error) // Fall through to download notification message diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index 43ad0d1..546b3c4 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -8,7 +8,7 @@ import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Net import { NodeMetricsCharts } from "./node-metrics-charts" import { NetworkTrafficChart } from "./network-traffic-chart" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" -import { getApiUrl } from "../lib/api-config" +import { fetchApi } from "../lib/api-config" interface SystemData { cpu_usage: number @@ -98,21 +98,7 @@ interface ProxmoxStorageData { const fetchSystemData = async (): Promise => { try { - const apiUrl = getApiUrl("/api/system") - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - throw new Error(`Flask server responded with status: ${response.status}`) - } - - const data = await response.json() + const data = await fetchApi("/api/system") return data } catch (error) { console.error("[v0] Failed to fetch system data:", error) @@ -122,21 +108,7 @@ const fetchSystemData = async (): Promise => { const fetchVMData = async (): Promise => { try { - const apiUrl = getApiUrl("/api/vms") - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - throw new Error(`Flask server responded with status: ${response.status}`) - } - - const data = await response.json() + const data = await fetchApi("/api/vms") return Array.isArray(data) ? data : data.vms || [] } catch (error) { console.error("[v0] Failed to fetch VM data:", error) @@ -146,75 +118,30 @@ const fetchVMData = async (): Promise => { const fetchStorageData = async (): Promise => { try { - const apiUrl = getApiUrl("/api/storage/summary") - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - console.log("[v0] Storage API not available (this is normal if not configured)") - return null - } - - const data = await response.json() + const data = await fetchApi("/api/storage/summary") return data } catch (error) { - console.log("[v0] Storage data unavailable:", error instanceof Error ? error.message : "Unknown error") + console.log("[v0] Storage API not available (this is normal if not configured)") return null } } const fetchNetworkData = async (): Promise => { try { - const apiUrl = getApiUrl("/api/network/summary") - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - console.log("[v0] Network API not available (this is normal if not configured)") - return null - } - - const data = await response.json() + const data = await fetchApi("/api/network/summary") return data } catch (error) { - console.log("[v0] Network data unavailable:", error instanceof Error ? error.message : "Unknown error") + console.log("[v0] Network API not available (this is normal if not configured)") return null } } const fetchProxmoxStorageData = async (): Promise => { try { - const apiUrl = getApiUrl("/api/proxmox-storage") - - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - }) - - if (!response.ok) { - console.log("[v0] Proxmox storage API not available") - return null - } - - const data = await response.json() + const data = await fetchApi("/api/proxmox-storage") return data } catch (error) { - console.log("[v0] Proxmox storage data unavailable:", error instanceof Error ? error.message : "Unknown error") + console.log("[v0] Proxmox storage API not available") return null } } diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 9a0fd49..6687b2a 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -26,6 +26,7 @@ import { import useSWR from "swr" import { MetricsView } from "./metrics-dialog" import { formatStorage } from "@/lib/utils" // Import formatStorage utility +import { fetchApi } from "../lib/api-config" interface VMData { vmid: number @@ -133,20 +134,7 @@ interface VMDetails extends VMData { } const fetcher = async (url: string) => { - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - signal: AbortSignal.timeout(60000), - }) - - if (!response.ok) { - throw new Error(`Flask server responded with status: ${response.status}`) - } - - const data = await response.json() - return data + return fetchApi(url) } const formatBytes = (bytes: number | undefined): string => { @@ -310,19 +298,14 @@ export function VirtualMachines() { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) - const response = await fetch(`/api/vms/${lxc.vmid}`, { - signal: controller.signal, - }) + const details = await fetchApi(`/api/vms/${lxc.vmid}`) clearTimeout(timeoutId) - if (response.ok) { - const details = await response.json() - if (details.lxc_ip_info?.primary_ip) { - configs[lxc.vmid] = details.lxc_ip_info.primary_ip - } else if (details.config) { - configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info) - } + if (details.lxc_ip_info?.primary_ip) { + configs[lxc.vmid] = details.lxc_ip_info.primary_ip + } else if (details.config) { + configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info) } } catch (error) { console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`) @@ -350,11 +333,8 @@ export function VirtualMachines() { setEditedNotes("") setDetailsLoading(true) try { - const response = await fetch(`/api/vms/${vm.vmid}`) - if (response.ok) { - const details = await response.json() - setVMDetails(details) - } + const details = await fetchApi(`/api/vms/${vm.vmid}`) + setVMDetails(details) } catch (error) { console.error("Error fetching VM details:", error) } finally { @@ -373,23 +353,16 @@ export function VirtualMachines() { const handleVMControl = async (vmid: number, action: string) => { setControlLoading(true) try { - const response = await fetch(`/api/vms/${vmid}/control`, { + await fetchApi(`/api/vms/${vmid}/control`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, body: JSON.stringify({ action }), }) - if (response.ok) { - mutate() - setSelectedVM(null) - setVMDetails(null) - } else { - console.error("Failed to control VM") - } + mutate() + setSelectedVM(null) + setVMDetails(null) } catch (error) { - console.error("Error controlling VM:", error) + console.error("Failed to control VM") } finally { setControlLoading(false) } @@ -397,36 +370,33 @@ export function VirtualMachines() { const handleDownloadLogs = async (vmid: number, vmName: string) => { try { - const response = await fetch(`/api/vms/${vmid}/logs`) - if (response.ok) { - const data = await response.json() + const data = await fetchApi(`/api/vms/${vmid}/logs`) - // Format logs as plain text - let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n` - logText += `Node: ${data.node}\n` - logText += `Type: ${data.type}\n` - logText += `Total lines: ${data.log_lines}\n` - logText += `Generated: ${new Date().toISOString()}\n` - logText += `\n${"=".repeat(80)}\n\n` + // Format logs as plain text + let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n` + logText += `Node: ${data.node}\n` + logText += `Type: ${data.type}\n` + logText += `Total lines: ${data.log_lines}\n` + logText += `Generated: ${new Date().toISOString()}\n` + logText += `\n${"=".repeat(80)}\n\n` - if (data.logs && Array.isArray(data.logs)) { - data.logs.forEach((log: any) => { - if (typeof log === "object" && log.t) { - logText += `${log.t}\n` - } else if (typeof log === "string") { - logText += `${log}\n` - } - }) - } - - const blob = new Blob([logText], { type: "text/plain" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = `${vmName}-${vmid}-logs.txt` - a.click() - URL.revokeObjectURL(url) + if (data.logs && Array.isArray(data.logs)) { + data.logs.forEach((log: any) => { + if (typeof log === "object" && log.t) { + logText += `${log.t}\n` + } else if (typeof log === "string") { + logText += `${log}\n` + } + }) } + + const blob = new Blob([logText], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${vmName}-${vmid}-logs.txt` + a.click() + URL.revokeObjectURL(url) } catch (error) { console.error("Error downloading logs:", error) } @@ -621,29 +591,21 @@ export function VirtualMachines() { setSavingNotes(true) try { - const response = await fetch(`/api/vms/${selectedVM.vmid}/config`, { + await fetchApi(`/api/vms/${selectedVM.vmid}/config`, { method: "PUT", - headers: { - "Content-Type": "application/json", - }, body: JSON.stringify({ description: editedNotes, // Send as-is, pvesh will handle encoding }), }) - if (response.ok) { - setVMDetails({ - ...vmDetails, - config: { - ...vmDetails.config, - description: editedNotes, // Store unencoded - }, - }) - setIsEditingNotes(false) - } else { - console.error("Failed to save notes") - alert("Failed to save notes. Please try again.") - } + setVMDetails({ + ...vmDetails, + config: { + ...vmDetails.config, + description: editedNotes, // Store unencoded + }, + }) + setIsEditingNotes(false) } catch (error) { console.error("Error saving notes:", error) alert("Error saving notes. Please try again.") diff --git a/AppImage/lib/api-config.ts b/AppImage/lib/api-config.ts index 8ee2ff6..34175c9 100644 --- a/AppImage/lib/api-config.ts +++ b/AppImage/lib/api-config.ts @@ -60,6 +60,23 @@ export function getApiUrl(endpoint: string): string { return `${baseUrl}${normalizedEndpoint}` } +/** + * Gets the JWT token from localStorage + * + * @returns JWT token or null if not authenticated + */ +export function getAuthToken(): string | null { + if (typeof window === "undefined") { + return null + } + const token = localStorage.getItem("proxmenux-auth-token") + console.log( + "[v0] getAuthToken called:", + token ? `Token found (length: ${token.length})` : "No token found in localStorage", + ) + return token +} + /** * Fetches data from an API endpoint with error handling * @@ -70,18 +87,40 @@ export function getApiUrl(endpoint: string): string { export async function fetchApi(endpoint: string, options?: RequestInit): Promise { const url = getApiUrl(endpoint) - const response = await fetch(url, { - ...options, - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - cache: "no-store", - }) + const token = getAuthToken() - if (!response.ok) { - throw new Error(`API request failed: ${response.status} ${response.statusText}`) + const headers: Record = { + "Content-Type": "application/json", + ...(options?.headers as Record), } - return response.json() + if (token) { + headers["Authorization"] = `Bearer ${token}` + console.log("[v0] fetchApi:", endpoint, "- Authorization header ADDED") + } else { + console.log("[v0] fetchApi:", endpoint, "- NO TOKEN - Request will fail if endpoint is protected") + } + + try { + const response = await fetch(url, { + ...options, + headers, + cache: "no-store", + }) + + console.log("[v0] fetchApi:", endpoint, "- Response status:", response.status) + + if (!response.ok) { + if (response.status === 401) { + console.error("[v0] fetchApi: 401 UNAUTHORIZED -", endpoint, "- Token present:", !!token) + throw new Error(`Unauthorized: ${endpoint}`) + } + throw new Error(`API request failed: ${response.status} ${response.statusText}`) + } + + return response.json() + } catch (error) { + console.error("[v0] fetchApi error for", endpoint, ":", error) + throw error + } } diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 76d2045..d92686b 100644 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -80,6 +80,7 @@ echo "📋 Copying Flask server..." cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found" cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found" +cp "$SCRIPT_DIR/jwt_middleware.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ jwt_middleware.py not found" cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found" cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found" cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found" diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py index 00f4f5f..d5b5e8d 100644 --- a/AppImage/scripts/flask_auth_routes.py +++ b/AppImage/scripts/flask_auth_routes.py @@ -5,6 +5,8 @@ Provides REST API endpoints for authentication management from flask import Blueprint, jsonify, request import auth_manager +import jwt +import datetime auth_bp = Blueprint('auth', __name__) @@ -135,7 +137,12 @@ def auth_skip(): success, message = auth_manager.decline_auth() if success: - return jsonify({"success": True, "message": message}) + # Return success with clear indication that APIs should be accessible + return jsonify({ + "success": True, + "message": message, + "auth_declined": True # Add explicit flag for frontend + }) else: return jsonify({"success": False, "message": message}), 400 except Exception as e: @@ -218,3 +225,54 @@ def totp_disable(): return jsonify({"success": False, "message": message}), 400 except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 + + +@auth_bp.route('/api/auth/generate-api-token', methods=['POST']) +def generate_api_token(): + """Generate a long-lived API token for external integrations (Homepage, Home Assistant, etc.)""" + try: + auth_header = request.headers.get('Authorization', '') + token = auth_header.replace('Bearer ', '') + + if not token: + return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401 + + username = auth_manager.verify_token(token) + + if not username: + return jsonify({"success": False, "message": "Invalid or expired session. Please log in again."}), 401 + + data = request.json + password = data.get('password') + totp_token = data.get('totp_token') # Optional 2FA token + token_name = data.get('token_name', 'API Token') # Optional token description + + if not password: + return jsonify({"success": False, "message": "Password is required"}), 400 + + # Authenticate user with password and optional 2FA + success, _, requires_totp, message = auth_manager.authenticate(username, password, totp_token) + + if success: + # Generate a long-lived token (1 year expiration) + api_token = jwt.encode({ + 'username': username, + 'token_name': token_name, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=365), + 'iat': datetime.datetime.utcnow() + }, auth_manager.JWT_SECRET, algorithm='HS256') + + return jsonify({ + "success": True, + "token": api_token, + "token_name": token_name, + "expires_in": "365 days", + "message": "API token generated successfully. Store this token securely, it will not be shown again." + }) + elif requires_totp: + return jsonify({"success": False, "requires_totp": True, "message": message}), 200 + else: + return jsonify({"success": False, "message": message}), 401 + except Exception as e: + print(f"[ERROR] generate_api_token: {str(e)}") # Log error for debugging + return jsonify({"success": False, "message": f"Internal error: {str(e)}"}), 500 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 7742e21..f040ef9 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -35,6 +35,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from flask_auth_routes import auth_bp from flask_proxmenux_routes import proxmenux_bp +from jwt_middleware import require_auth app = Flask(__name__) CORS(app) # Enable CORS for Next.js frontend @@ -1740,6 +1741,7 @@ def get_proxmox_storage(): # END OF CHANGES FOR get_proxmox_storage @app.route('/api/storage/summary', methods=['GET']) +@require_auth def api_storage_summary(): """Get storage summary without SMART data (optimized for Overview page)""" try: @@ -3474,7 +3476,7 @@ def get_detailed_gpu_info(gpu): 'shared': 0, 'resident': int(vram_mb * 1024 * 1024) } - # print(f"[v0] VRAM: {vram_mb} MB", flush=True) + # print(f"[v0] VRAM: {vram_mb} MB", flush=True) pass # Parse GTT (Graphics Translation Table) usage (está dentro de usage.usage) @@ -3488,7 +3490,7 @@ def get_detailed_gpu_info(gpu): else: # Add GTT to existing VRAM process_info['memory']['total'] += int(gtt_mb * 1024 * 1024) - # print(f"[v0] GTT: {gtt_mb} MB", flush=True) + # print(f"[v0] GTT: {gtt_mb} MB", flush=True) pass # Parse engine utilization for this process (están dentro de usage.usage) @@ -4519,6 +4521,7 @@ def get_hardware_info(): @app.route('/api/system', methods=['GET']) +@require_auth def api_system(): """Get system information including CPU, memory, and temperature""" try: @@ -4575,21 +4578,25 @@ def api_system(): return jsonify({'error': str(e)}), 500 @app.route('/api/storage', methods=['GET']) +@require_auth def api_storage(): """Get storage information""" return jsonify(get_storage_info()) @app.route('/api/proxmox-storage', methods=['GET']) +@require_auth def api_proxmox_storage(): """Get Proxmox storage information""" return jsonify(get_proxmox_storage()) @app.route('/api/network', methods=['GET']) +@require_auth def api_network(): """Get network information""" return jsonify(get_network_info()) @app.route('/api/network/summary', methods=['GET']) +@require_auth def api_network_summary(): """Optimized network summary endpoint - returns basic network info without detailed analysis""" try: @@ -4668,6 +4675,7 @@ def api_network_summary(): return jsonify({'error': str(e)}), 500 @app.route('/api/network//metrics', methods=['GET']) +@require_auth def api_network_interface_metrics(interface_name): """Get historical metrics (RRD data) for a specific network interface""" try: @@ -4750,12 +4758,13 @@ def api_network_interface_metrics(interface_name): return jsonify({'error': str(e)}), 500 @app.route('/api/vms', methods=['GET']) +@require_auth def api_vms(): """Get virtual machine information""" return jsonify(get_proxmox_vms()) -# Add the new api_vm_metrics endpoint here @app.route('/api/vms//metrics', methods=['GET']) +@require_auth def api_vm_metrics(vmid): """Get historical metrics (RRD data) for a specific VM/LXC""" try: @@ -4822,6 +4831,7 @@ def api_vm_metrics(vmid): return jsonify({'error': str(e)}), 500 @app.route('/api/node/metrics', methods=['GET']) +@require_auth def api_node_metrics(): """Get historical metrics (RRD data) for the node""" try: @@ -4865,6 +4875,7 @@ def api_node_metrics(): return jsonify({'error': str(e)}), 500 @app.route('/api/logs', methods=['GET']) +@require_auth def api_logs(): """Get system logs""" try: @@ -4942,6 +4953,7 @@ def api_logs(): }) @app.route('/api/logs/download', methods=['GET']) +@require_auth def api_logs_download(): """Download system logs as a text file""" try: @@ -5000,6 +5012,7 @@ def api_logs_download(): return jsonify({'error': str(e)}), 500 @app.route('/api/notifications', methods=['GET']) +@require_auth def api_notifications(): """Get Proxmox notification history""" try: @@ -5116,6 +5129,7 @@ def api_notifications(): }) @app.route('/api/notifications/download', methods=['GET']) +@require_auth def api_notifications_download(): """Download complete log for a specific notification""" try: @@ -5171,6 +5185,7 @@ def api_notifications_download(): return jsonify({'error': str(e)}), 500 @app.route('/api/backups', methods=['GET']) +@require_auth def api_backups(): """Get list of all backup files from Proxmox storage""" try: @@ -5259,6 +5274,7 @@ def api_backups(): }) @app.route('/api/events', methods=['GET']) +@require_auth def api_events(): """Get recent Proxmox events and tasks""" try: @@ -5335,6 +5351,7 @@ def api_events(): }) @app.route('/api/task-log/') +@require_auth def get_task_log(upid): """Get complete task log from Proxmox using UPID""" try: @@ -5432,6 +5449,7 @@ def get_task_log(upid): return jsonify({'error': str(e)}), 500 @app.route('/api/health', methods=['GET']) +@require_auth def api_health(): """Health check endpoint""" return jsonify({ @@ -5441,6 +5459,7 @@ def api_health(): }) @app.route('/api/prometheus', methods=['GET']) +@require_auth def api_prometheus(): """Export metrics in Prometheus format""" try: @@ -5697,11 +5716,12 @@ def api_prometheus(): @app.route('/api/info', methods=['GET']) +@require_auth def api_info(): """Root endpoint with API information""" return jsonify({ 'name': 'ProxMenux Monitor API', - 'version': '1.0.0', + 'version': '1.0.1', 'endpoints': [ '/api/system', '/api/system-info', @@ -5725,6 +5745,7 @@ def api_info(): }) @app.route('/api/hardware', methods=['GET']) +@require_auth def api_hardware(): """Get hardware information""" try: @@ -5761,6 +5782,7 @@ def api_hardware(): return jsonify({'error': str(e)}), 500 @app.route('/api/gpu//realtime', methods=['GET']) +@require_auth def api_gpu_realtime(slot): """Get real-time GPU monitoring data for a specific GPU""" try: @@ -5823,6 +5845,7 @@ def api_gpu_realtime(slot): # CHANGE: Modificar el endpoint para incluir la información completa de IPs @app.route('/api/vms/', methods=['GET']) +@require_auth def get_vm_config(vmid): """Get detailed configuration for a specific VM/LXC""" try: @@ -5919,6 +5942,7 @@ def get_vm_config(vmid): return jsonify({'error': str(e)}), 500 @app.route('/api/vms//logs', methods=['GET']) +@require_auth def api_vm_logs(vmid): """Download real logs for a specific VM/LXC (not task history)""" try: @@ -5968,6 +5992,7 @@ def api_vm_logs(vmid): return jsonify({'error': str(e)}), 500 @app.route('/api/vms//control', methods=['POST']) +@require_auth def api_vm_control(vmid): """Control VM/LXC (start, stop, shutdown, reboot)""" try: @@ -6020,6 +6045,7 @@ def api_vm_control(vmid): return jsonify({'error': str(e)}), 500 @app.route('/api/vms//config', methods=['PUT']) +@require_auth def api_vm_config_update(vmid): """Update VM/LXC configuration (description/notes)""" try: diff --git a/AppImage/scripts/jwt_middleware.py b/AppImage/scripts/jwt_middleware.py new file mode 100644 index 0000000..291edcf --- /dev/null +++ b/AppImage/scripts/jwt_middleware.py @@ -0,0 +1,98 @@ +""" +JWT Middleware Module +Provides decorator to protect Flask routes with JWT authentication +Automatically checks auth status and validates tokens +""" + +from flask import request, jsonify +from functools import wraps +from auth_manager import load_auth_config, verify_token + + +def require_auth(f): + """ + Decorator to protect Flask routes with JWT authentication + + Behavior: + - If auth is disabled or declined: Allow access (no token required) + - If auth is enabled: Require valid JWT token in Authorization header + - Returns 401 if auth required but token missing/invalid + + Usage: + @app.route('/api/protected') + @require_auth + def protected_route(): + return jsonify({"data": "secret"}) + """ + @wraps(f) + def decorated_function(*args, **kwargs): + # Check if authentication is enabled + config = load_auth_config() + + # If auth is disabled or declined, allow access + if not config.get("enabled", False) or config.get("declined", False): + return f(*args, **kwargs) + + # Auth is enabled, require token + auth_header = request.headers.get('Authorization') + + if not auth_header: + return jsonify({ + "error": "Authentication required", + "message": "No authorization header provided" + }), 401 + + # Extract token from "Bearer " format + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != 'bearer': + return jsonify({ + "error": "Invalid authorization header", + "message": "Authorization header must be in format: Bearer " + }), 401 + + token = parts[1] + + # Verify token + username = verify_token(token) + if not username: + return jsonify({ + "error": "Invalid or expired token", + "message": "Please log in again" + }), 401 + + # Token is valid, allow access + return f(*args, **kwargs) + + return decorated_function + + +def optional_auth(f): + """ + Decorator for routes that can optionally use auth + Passes username if authenticated, None otherwise + + Usage: + @app.route('/api/optional') + @optional_auth + def optional_route(username=None): + if username: + return jsonify({"message": f"Hello {username}"}) + return jsonify({"message": "Hello guest"}) + """ + @wraps(f) + def decorated_function(*args, **kwargs): + config = load_auth_config() + username = None + + if config.get("enabled", False): + auth_header = request.headers.get('Authorization') + if auth_header: + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + username = verify_token(parts[1]) + + # Inject username into kwargs + kwargs['username'] = username + return f(*args, **kwargs) + + return decorated_function diff --git a/AppImage/types/hardware.ts b/AppImage/types/hardware.ts index c31de3c..90561ac 100644 --- a/AppImage/types/hardware.ts +++ b/AppImage/types/hardware.ts @@ -1,3 +1,5 @@ +import { fetchApi } from "@/lib/api-config" + export interface Temperature { name: string original_name?: string @@ -208,4 +210,8 @@ export interface HardwareData { ups?: UPS | UPS[] } -export const fetcher = (url: string) => fetch(url).then((res) => res.json()) +export const fetcher = async (url: string) => { + // Extract just the endpoint from the URL if it's a full URL + const endpoint = url.startsWith("http") ? new URL(url).pathname : url + return fetchApi(endpoint) +}