Merge branch 'MacRimi:main' into main

This commit is contained in:
cod378
2025-11-13 23:08:13 -03:00
committed by GitHub
20 changed files with 1559 additions and 501 deletions

View File

@@ -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:
<p align="center">
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
<br/>
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen2.png" alt="Storage Management" width="800"/>
<br/>
<em>Storage Management - Visual representation of disk usage and health</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen3.png" alt="Network Monitoring" width="800"/>
<br/>
<em>Network Monitoring - Real-time traffic graphs and interface statistics</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen4.png" alt="Virtual Machines & LXC" width="800"/>
<br/>
<em>VMs & LXC Containers - Comprehensive view with resource usage and controls</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen5.png" alt="Hardware Information" width="800"/>
<br/>
<em>Hardware Information - Detailed specs for CPU, GPU, and PCIe devices</em>
</p>
<p align="center">
<img src="public/images/onboarding/imagen6.png" alt="System Logs" width="800"/>
<br/>
<em>System Logs - Real-time monitoring with filtering and search</em>
</p>
---
## 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/<interface>/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/<vmid>` | GET | Yes | Detailed configuration for specific VM/LXC |
| `/api/vms/<vmid>/metrics` | GET | Yes | Historical metrics (RRD) for specific VM/LXC |
| `/api/vms/<vmid>/logs` | GET | Yes | Download real logs for specific VM/LXC |
| `/api/vms/<vmid>/control` | POST | Yes | Control VM/LXC (start, stop, shutdown, reboot) |
| `/api/vms/<vmid>/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/<slot>/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/<upid>` | 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

View File

@@ -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) {

View File

@@ -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<HardwareData>("/api/hardware", fetcher, {
} = useSWR<HardwareData>("/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<HardwareData>("/api/hardware", fetcher, {
} = useSWR<HardwareData>("/api/hardware", swrFetcher, {
refreshInterval: 7000,
})
@@ -231,6 +237,21 @@ export default function Hardware() {
const [selectedNetwork, setSelectedNetwork] = useState<PCIDevice | null>(null)
const [selectedUPS, setSelectedUPS] = useState<any>(null)
const fetcher = async (url: string) => {
const data = await fetchApi(url)
return data
}
const {
data: hardwareDataSWR,
error: swrError,
isLoading: swrLoading,
mutate,
} = useSWR<HardwareData>("/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 (
<div className="space-y-6">
<div className="text-center py-8">
@@ -333,7 +331,7 @@ export default function Hardware() {
return (
<div className="space-y-6">
{/* System Information - CPU & Motherboard */}
{(hardwareData?.cpu || hardwareData?.motherboard) && (
{(hardwareDataSWR?.cpu || hardwareDataSWR?.motherboard) && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Cpu className="h-5 w-5 text-primary" />
@@ -342,44 +340,44 @@ export default function Hardware() {
<div className="grid gap-6 md:grid-cols-2">
{/* CPU Info */}
{hardwareData?.cpu && Object.keys(hardwareData.cpu).length > 0 && (
{hardwareDataSWR?.cpu && Object.keys(hardwareDataSWR.cpu).length > 0 && (
<div>
<div className="mb-2 flex items-center gap-2">
<CpuIcon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">CPU</h3>
</div>
<div className="space-y-2">
{hardwareData.cpu.model && (
{hardwareDataSWR.cpu.model && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Model</span>
<span className="font-medium text-right">{hardwareData.cpu.model}</span>
<span className="font-medium text-right">{hardwareDataSWR.cpu.model}</span>
</div>
)}
{hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && (
{hardwareDataSWR.cpu.cores_per_socket && hardwareDataSWR.cpu.sockets && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Cores</span>
<span className="font-medium">
{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
</span>
</div>
)}
{hardwareData.cpu.total_threads && (
{hardwareDataSWR.cpu.total_threads && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Threads</span>
<span className="font-medium">{hardwareData.cpu.total_threads}</span>
<span className="font-medium">{hardwareDataSWR.cpu.total_threads}</span>
</div>
)}
{hardwareData.cpu.l3_cache && (
{hardwareDataSWR.cpu.l3_cache && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">L3 Cache</span>
<span className="font-medium">{hardwareData.cpu.l3_cache}</span>
<span className="font-medium">{hardwareDataSWR.cpu.l3_cache}</span>
</div>
)}
{hardwareData.cpu.virtualization && (
{hardwareDataSWR.cpu.virtualization && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Virtualization</span>
<span className="font-medium">{hardwareData.cpu.virtualization}</span>
<span className="font-medium">{hardwareDataSWR.cpu.virtualization}</span>
</div>
)}
</div>
@@ -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 && (
<div>
<div className="mb-2 flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Motherboard</h3>
</div>
<div className="space-y-2">
{hardwareData.motherboard.manufacturer && (
{hardwareDataSWR.motherboard.manufacturer && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Manufacturer</span>
<span className="font-medium text-right">{hardwareData.motherboard.manufacturer}</span>
<span className="font-medium text-right">{hardwareDataSWR.motherboard.manufacturer}</span>
</div>
)}
{hardwareData.motherboard.model && (
{hardwareDataSWR.motherboard.model && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Model</span>
<span className="font-medium text-right">{hardwareData.motherboard.model}</span>
<span className="font-medium text-right">{hardwareDataSWR.motherboard.model}</span>
</div>
)}
{hardwareData.motherboard.bios?.vendor && (
{hardwareDataSWR.motherboard.bios?.vendor && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">BIOS</span>
<span className="font-medium text-right">{hardwareData.motherboard.bios.vendor}</span>
<span className="font-medium text-right">{hardwareDataSWR.motherboard.bios.vendor}</span>
</div>
)}
{hardwareData.motherboard.bios?.version && (
{hardwareDataSWR.motherboard.bios?.version && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Version</span>
<span className="font-medium">{hardwareData.motherboard.bios.version}</span>
<span className="font-medium">{hardwareDataSWR.motherboard.bios.version}</span>
</div>
)}
{hardwareData.motherboard.bios?.date && (
{hardwareDataSWR.motherboard.bios?.date && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Date</span>
<span className="font-medium">{hardwareData.motherboard.bios.date}</span>
<span className="font-medium">{hardwareDataSWR.motherboard.bios.date}</span>
</div>
)}
</div>
@@ -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 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<MemoryStick className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Memory Modules</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.memory_modules.length} installed
{hardwareDataSWR.memory_modules.length} installed
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{hardwareData.memory_modules.map((module, index) => (
{hardwareDataSWR.memory_modules.map((module, index) => (
<div key={index} className="rounded-lg border border-border/30 bg-background/60 p-4">
<div className="mb-2 font-medium text-sm">{module.slot}</div>
<div className="space-y-1">
@@ -479,29 +477,29 @@ export default function Hardware() {
)}
{/* Thermal Monitoring */}
{hardwareData?.temperatures && hardwareData.temperatures.length > 0 && (
{hardwareDataSWR?.temperatures && hardwareDataSWR.temperatures.length > 0 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Thermometer className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Thermal Monitoring</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.temperatures.length} sensors
{hardwareDataSWR.temperatures.length} sensors
</Badge>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* CPU Sensors */}
{groupAndSortTemperatures(hardwareData.temperatures).CPU.length > 0 && (
{groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length > 0 && (
<div className="md:col-span-2">
<div className="mb-3 flex items-center gap-2">
<CpuIcon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">CPU</h3>
<Badge variant="outline" className="text-xs">
{groupAndSortTemperatures(hardwareData.temperatures).CPU.length}
{groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length}
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-2">
{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 && (
<div
className={groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 1 ? "md:col-span-2" : ""}
className={groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length > 1 ? "md:col-span-2" : ""}
>
<div className="mb-3 flex items-center gap-2">
<Gpu className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">GPU</h3>
<Badge variant="outline" className="text-xs">
{groupAndSortTemperatures(hardwareData.temperatures).GPU.length}
{groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length}
</Badge>
</div>
<div
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.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 && (
<div
className={groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 1 ? "md:col-span-2" : ""}
className={
groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length > 1 ? "md:col-span-2" : ""
}
>
<div className="mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">NVME</h3>
<Badge variant="outline" className="text-xs">
{groupAndSortTemperatures(hardwareData.temperatures).NVME.length}
{groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length}
</Badge>
</div>
<div
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.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 && (
<div
className={groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 1 ? "md:col-span-2" : ""}
className={groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length > 1 ? "md:col-span-2" : ""}
>
<div className="mb-3 flex items-center gap-2">
<CpuIcon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">PCI</h3>
<Badge variant="outline" className="text-xs">
{groupAndSortTemperatures(hardwareData.temperatures).PCI.length}
{groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length}
</Badge>
</div>
<div
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.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 && (
<div
className={groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 1 ? "md:col-span-2" : ""}
className={
groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length > 1 ? "md:col-span-2" : ""
}
>
<div className="mb-3 flex items-center gap-2">
<Thermometer className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">OTHER</h3>
<Badge variant="outline" className="text-xs">
{groupAndSortTemperatures(hardwareData.temperatures).OTHER.length}
{groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length}
</Badge>
</div>
<div
className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.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 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Gpu className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Graphics Cards</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""}
{hardwareDataSWR.gpus.length} GPU{hardwareDataSWR.gpus.length > 1 ? "s" : ""}
</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{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() {
</Dialog>
{/* PCI Devices - Changed to modal */}
{hardwareData?.pci_devices && hardwareData.pci_devices.length > 0 && (
{hardwareDataSWR?.pci_devices && hardwareDataSWR.pci_devices.length > 0 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<CpuIcon className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">PCI Devices</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.pci_devices.length} devices
{hardwareDataSWR.pci_devices.length} devices
</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hardwareData.pci_devices.map((device, index) => (
{hardwareDataSWR.pci_devices.map((device, index) => (
<div
key={index}
onClick={() => setSelectedPCIDevice(device)}
@@ -1190,7 +1192,7 @@ export default function Hardware() {
</Dialog>
{/* Power Consumption */}
{hardwareData?.power_meter && (
{hardwareDataSWR?.power_meter && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Zap className="h-5 w-5 text-blue-500" />
@@ -1200,13 +1202,13 @@ export default function Hardware() {
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border/30 bg-background/60 p-4">
<div className="space-y-1">
<p className="text-sm font-medium">{hardwareData.power_meter.name}</p>
{hardwareData.power_meter.adapter && (
<p className="text-xs text-muted-foreground">{hardwareData.power_meter.adapter}</p>
<p className="text-sm font-medium">{hardwareDataSWR.power_meter.name}</p>
{hardwareDataSWR.power_meter.adapter && (
<p className="text-xs text-muted-foreground">{hardwareDataSWR.power_meter.adapter}</p>
)}
</div>
<div className="text-right">
<p className="text-2xl font-bold text-blue-500">{hardwareData.power_meter.watts.toFixed(1)} W</p>
<p className="text-2xl font-bold text-blue-500">{hardwareDataSWR.power_meter.watts.toFixed(1)} W</p>
<p className="text-xs text-muted-foreground">Current Draw</p>
</div>
</div>
@@ -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 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<PowerIcon className="h-5 w-5 text-green-500" />
<h2 className="text-lg font-semibold">Power Supplies</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.power_supplies.length} PSUs
{hardwareDataSWR.power_supplies.length} PSUs
</Badge>
</div>
<div className="grid gap-3 md:grid-cols-2">
{hardwareData.power_supplies.map((psu, index) => (
{hardwareDataSWR.power_supplies.map((psu, index) => (
<div key={index} className="rounded-lg border border-border/30 bg-background/60 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{psu.name}</span>
@@ -1243,18 +1245,18 @@ export default function Hardware() {
)}
{/* Fans */}
{hardwareData?.fans && hardwareData.fans.length > 0 && (
{hardwareDataSWR?.fans && hardwareDataSWR.fans.length > 0 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<FanIcon className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">System Fans</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.fans.length} fans
{hardwareDataSWR.fans.length} fans
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-2">
{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 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Battery className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">UPS Status</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.ups.length} UPS
{hardwareDataSWR.ups.length} UPS
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-2">
{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() {
</Dialog>
{/* 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 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<Network className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Network Summary</h2>
<Badge variant="outline" className="ml-auto">
{hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces
{hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces
</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hardwareData.pci_devices
{hardwareDataSWR.pci_devices
.filter((d) => d.type.toLowerCase().includes("network"))
.map((device, index) => (
<div
@@ -1652,14 +1654,14 @@ export default function Hardware() {
</Dialog>
{/* Storage Summary - Clickable */}
{hardwareData?.storage_devices && hardwareData.storage_devices.length > 0 && (
{hardwareDataSWR?.storage_devices && hardwareDataSWR.storage_devices.length > 0 && (
<Card className="border-border/50 bg-card/50 p-6">
<div className="mb-4 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">Storage Summary</h2>
<Badge variant="outline" className="ml-auto">
{
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() {
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{hardwareData.storage_devices
{hardwareDataSWR.storage_devices
.filter(
(device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"),
)

View File

@@ -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<any>(`/api/vms/${vmid}/metrics?timeframe=${timeframe}`)
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)

View File

@@ -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)
}

View File

@@ -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<NetworkData> => {
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<NetworkData>(url)
}
export function NetworkMetrics() {

View File

@@ -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<any>(apiPath)
if (!result.data || !Array.isArray(result.data)) {
throw new Error("Invalid data format received from server")

View File

@@ -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<any>(`/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)

View File

@@ -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"

View File

@@ -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 && (
<Button
onClick={() => setShow2FASetup(true)}
variant="outline"
className="w-full bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/20"
>
<Shield className="h-4 w-4 mr-2" />
Enable Two-Factor Authentication
</Button>
<div className="space-y-3">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-400">
<p className="font-medium mb-1">Two-Factor Authentication (2FA)</p>
<p className="text-blue-300">
Add an extra layer of security by requiring a code from your authenticator app in addition to
your password.
</p>
</div>
</div>
<Button onClick={() => setShow2FASetup(true)} variant="outline" className="w-full">
<Shield className="h-4 w-4 mr-2" />
Enable Two-Factor Authentication
</Button>
</div>
)}
{totpEnabled && (
@@ -577,6 +662,199 @@ export function Settings() {
</CardContent>
</Card>
{/* API Access Tokens */}
{authEnabled && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-500" />
<CardTitle>API Access Tokens</CardTitle>
</div>
<CardDescription>
Generate long-lived API tokens for external integrations like Homepage and Home Assistant
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
{success && (
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-500">{success}</p>
</div>
)}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="space-y-2 text-sm text-blue-400">
<p className="font-medium">About API Tokens</p>
<ul className="list-disc list-inside space-y-1 text-blue-300">
<li>Tokens are valid for 1 year</li>
<li>Use them to access APIs from external services</li>
<li>Include in Authorization header: Bearer YOUR_TOKEN</li>
<li>See README.md for complete integration examples</li>
</ul>
</div>
</div>
</div>
{!showApiTokenSection && !apiToken && (
<Button onClick={() => setShowApiTokenSection(true)} className="w-full bg-purple-500 hover:bg-purple-600">
<Key className="h-4 w-4 mr-2" />
Generate New API Token
</Button>
)}
{showApiTokenSection && !apiToken && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Generate API Token</h3>
<p className="text-sm text-muted-foreground">
Enter your credentials to generate a new long-lived API token
</p>
<div className="space-y-2">
<Label htmlFor="token-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="token-password"
type="password"
placeholder="Enter your password"
value={tokenPassword}
onChange={(e) => setTokenPassword(e.target.value)}
className="pl-10"
disabled={generatingToken}
/>
</div>
</div>
{totpEnabled && (
<div className="space-y-2">
<Label htmlFor="token-totp">2FA Code</Label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="token-totp"
type="text"
placeholder="Enter 6-digit code"
value={tokenTotpCode}
onChange={(e) => setTokenTotpCode(e.target.value)}
className="pl-10"
maxLength={6}
disabled={generatingToken}
/>
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleGenerateApiToken}
className="flex-1 bg-purple-500 hover:bg-purple-600"
disabled={generatingToken}
>
{generatingToken ? "Generating..." : "Generate Token"}
</Button>
<Button
onClick={() => {
setShowApiTokenSection(false)
setTokenPassword("")
setTokenTotpCode("")
setError("")
}}
variant="outline"
className="flex-1"
disabled={generatingToken}
>
Cancel
</Button>
</div>
</div>
)}
{apiToken && (
<div className="space-y-4 border border-green-500/20 bg-green-500/5 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-500">
<CheckCircle className="h-5 w-5" />
<h3 className="font-semibold">Your API Token</h3>
</div>
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm text-amber-600 dark:text-amber-400 font-semibold">
Important: Save this token now!
</p>
<p className="text-xs text-amber-600/80 dark:text-amber-400/80">
You won't be able to see it again. Store it securely.
</p>
</div>
</div>
<div className="space-y-2">
<Label>Token</Label>
<div className="relative">
<Input
value={apiToken}
readOnly
type={apiTokenVisible ? "text" : "password"}
className="pr-20 font-mono text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setApiTokenVisible(!apiTokenVisible)}
className="h-7 w-7 p-0"
>
{apiTokenVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button size="sm" variant="ghost" onClick={copyApiToken} className="h-7 w-7 p-0">
<Copy className={`h-4 w-4 ${tokenCopied ? "text-green-500" : ""}`} />
</Button>
</div>
</div>
{tokenCopied && (
<p className="text-xs text-green-500 flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
Copied to clipboard!
</p>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">How to use this token:</p>
<div className="bg-muted/50 rounded p-3 text-xs font-mono">
<p className="text-muted-foreground mb-2"># Add to request headers:</p>
<p>Authorization: Bearer YOUR_TOKEN_HERE</p>
</div>
<p className="text-xs text-muted-foreground">
See the README documentation for complete integration examples with Homepage and Home Assistant.
</p>
</div>
<Button
onClick={() => {
setApiToken("")
setShowApiTokenSection(false)
}}
variant="outline"
className="w-full"
>
Done
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* ProxMenux Optimizations */}
<Card>
<CardHeader>

View File

@@ -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<StorageData>("/api/storage"),
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
])
const data = await storageResponse.json()
const proxmoxData = await proxmoxResponse.json()
setStorageData(data)
setProxmoxStorage(proxmoxData)
} catch (error) {

View File

@@ -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<SystemLog[]> => {
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

View File

@@ -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<SystemData | null> => {
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<SystemData>("/api/system")
return data
} catch (error) {
console.error("[v0] Failed to fetch system data:", error)
@@ -122,21 +108,7 @@ const fetchSystemData = async (): Promise<SystemData | null> => {
const fetchVMData = async (): Promise<VMData[]> => {
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<any>("/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<VMData[]> => {
const fetchStorageData = async (): Promise<StorageData | null> => {
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<StorageData>("/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<NetworkData | null> => {
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<NetworkData>("/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<ProxmoxStorageData | null> => {
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<ProxmoxStorageData>("/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
}
}

View File

@@ -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.")

View File

@@ -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<T>(endpoint: string, options?: RequestInit): Promise<T> {
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<string, string> = {
"Content-Type": "application/json",
...(options?.headers as Record<string, string>),
}
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
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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/<interface_name>/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/<int:vmid>/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/<path:upid>')
@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/<slot>/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/<int:vmid>', 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/<int:vmid>/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/<int:vmid>/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/<int:vmid>/config', methods=['PUT'])
@require_auth
def api_vm_config_update(vmid):
"""Update VM/LXC configuration (description/notes)"""
try:

View File

@@ -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 <token>" 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 <token>"
}), 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

View File

@@ -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)
}