diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx
index c4f92e3..f37192d 100644
--- a/AppImage/components/storage-overview.tsx
+++ b/AppImage/components/storage-overview.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer } from "lucide-react"
+import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -394,10 +394,20 @@ export function StorageOverview() {
return "[&>div]:bg-red-500"
}
+ const getUsageColor = (percent: number): string => {
+ if (percent < 70) return "text-blue-500"
+ if (percent < 85) return "text-yellow-500"
+ if (percent < 95) return "text-orange-500"
+ return "text-red-500"
+ }
+
const diskHealthBreakdown = getDiskHealthBreakdown()
const diskTypesBreakdown = getDiskTypesBreakdown()
- const totalProxmoxUsed =
+ const localStorageTypes = ["dir", "lvmthin", "lvm", "zfspool", "btrfs"]
+ const remoteStorageTypes = ["pbs", "nfs", "cifs", "smb", "glusterfs", "iscsi", "iscsidirect", "rbd", "cephfs"]
+
+ const totalLocalUsed =
proxmoxStorage?.storage
.filter(
(storage) =>
@@ -406,11 +416,12 @@ export function StorageOverview() {
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
- storage.available >= 0,
+ storage.available >= 0 &&
+ localStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.used, 0) || 0
- const totalProxmoxCapacity =
+ const totalLocalCapacity =
proxmoxStorage?.storage
.filter(
(storage) =>
@@ -419,11 +430,52 @@ export function StorageOverview() {
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
- storage.available >= 0,
+ storage.available >= 0 &&
+ localStorageTypes.includes(storage.type.toLowerCase()),
)
.reduce((sum, storage) => sum + storage.total, 0) || 0
- const usagePercent = totalProxmoxCapacity > 0 ? ((totalProxmoxUsed / totalProxmoxCapacity) * 100).toFixed(2) : "0.00"
+ const localUsagePercent = totalLocalCapacity > 0 ? ((totalLocalUsed / totalLocalCapacity) * 100).toFixed(2) : "0.00"
+
+ const totalRemoteUsed =
+ proxmoxStorage?.storage
+ .filter(
+ (storage) =>
+ storage &&
+ storage.name &&
+ storage.status === "active" &&
+ storage.total > 0 &&
+ storage.used >= 0 &&
+ storage.available >= 0 &&
+ remoteStorageTypes.includes(storage.type.toLowerCase()),
+ )
+ .reduce((sum, storage) => sum + storage.used, 0) || 0
+
+ const totalRemoteCapacity =
+ proxmoxStorage?.storage
+ .filter(
+ (storage) =>
+ storage &&
+ storage.name &&
+ storage.status === "active" &&
+ storage.total > 0 &&
+ storage.used >= 0 &&
+ storage.available >= 0 &&
+ remoteStorageTypes.includes(storage.type.toLowerCase()),
+ )
+ .reduce((sum, storage) => sum + storage.total, 0) || 0
+
+ const remoteUsagePercent =
+ totalRemoteCapacity > 0 ? ((totalRemoteUsed / totalRemoteCapacity) * 100).toFixed(2) : "0.00"
+
+ const remoteStorageCount =
+ proxmoxStorage?.storage.filter(
+ (storage) =>
+ storage &&
+ storage.name &&
+ storage.status === "active" &&
+ remoteStorageTypes.includes(storage.type.toLowerCase()),
+ ).length || 0
if (loading) {
return (
@@ -458,64 +510,81 @@ export function StorageOverview() {
- Used Storage
+ Local Used
- {formatStorage(totalProxmoxUsed)}
- {usagePercent}% used
+ {formatStorage(totalLocalUsed)}
+
+ {localUsagePercent}%
+ of
+ {formatStorage(totalLocalCapacity)}
+
- {/* Disk Health */}
- Disk Health
-
+ Remote Used
+
- {storageData.disk_count} disks
+
+ {remoteStorageCount > 0 ? formatStorage(totalRemoteUsed) : "None"}
+
- {diskHealthBreakdown.normal} normal
- {diskHealthBreakdown.warning > 0 && (
+ {remoteStorageCount > 0 ? (
<>
- {", "}
- {diskHealthBreakdown.warning} warning
- >
- )}
- {diskHealthBreakdown.critical > 0 && (
- <>
- {", "}
- {diskHealthBreakdown.critical} critical
+ {remoteUsagePercent}%
+ of
+ {formatStorage(totalRemoteCapacity)}
>
+ ) : (
+ No remote storage
)}
- {/* Disk Types */}
- Disk Types
+ Physical Disks
{storageData.disk_count} disks
-
- {diskTypesBreakdown.nvme > 0 && {diskTypesBreakdown.nvme} NVMe}
- {diskTypesBreakdown.ssd > 0 && (
- <>
- {diskTypesBreakdown.nvme > 0 && ", "}
- {diskTypesBreakdown.ssd} SSD
- >
- )}
- {diskTypesBreakdown.hdd > 0 && (
- <>
- {(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
- {diskTypesBreakdown.hdd} HDD
- >
- )}
-
+
+
+ {diskTypesBreakdown.nvme > 0 && {diskTypesBreakdown.nvme} NVMe}
+ {diskTypesBreakdown.ssd > 0 && (
+ <>
+ {diskTypesBreakdown.nvme > 0 && ", "}
+ {diskTypesBreakdown.ssd} SSD
+ >
+ )}
+ {diskTypesBreakdown.hdd > 0 && (
+ <>
+ {(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
+ {diskTypesBreakdown.hdd} HDD
+ >
+ )}
+
+
+ {diskHealthBreakdown.normal} normal
+ {diskHealthBreakdown.warning > 0 && (
+ <>
+ {", "}
+ {diskHealthBreakdown.warning} warning
+ >
+ )}
+ {diskHealthBreakdown.critical > 0 && (
+ <>
+ {", "}
+ {diskHealthBreakdown.critical} critical
+ >
+ )}
+
+
@@ -533,11 +602,7 @@ export function StorageOverview() {
{proxmoxStorage.storage
.filter(
(storage) =>
- storage &&
- storage.name &&
- storage.total > 0 &&
- storage.used >= 0 && // Ensure used is not negative
- storage.available >= 0, // Ensure available is not negative
+ storage && storage.name && storage.total > 0 && storage.used >= 0 && storage.available >= 0,
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((storage) => (
diff --git a/AppImage/components/system-logs.tsx b/AppImage/components/system-logs.tsx
index d23b6f5..0ef6091 100644
--- a/AppImage/components/system-logs.tsx
+++ b/AppImage/components/system-logs.tsx
@@ -28,6 +28,7 @@ import {
Terminal,
} from "lucide-react"
import { useState, useEffect, useMemo } from "react"
+import { API_PORT } from "@/lib/api-config"
interface Log {
timestamp: string
@@ -131,10 +132,10 @@ export function SystemLogs() {
if (isStandardPort) {
return endpoint
} else {
- return `${protocol}//${hostname}:8008${endpoint}`
+ return `${protocol}//${hostname}:${API_PORT}${endpoint}`
}
}
- return `http://localhost:8008${endpoint}`
+ return `${protocol}//${hostname}:${API_PORT}${endpoint}`
}
useEffect(() => {
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx
index a9a4d15..9a0fd49 100644
--- a/AppImage/components/virtual-machines.tsx
+++ b/AppImage/components/virtual-machines.tsx
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import {
Server,
Play,
@@ -124,7 +124,6 @@ interface VMDetails extends VMData {
gpu_passthrough?: string[]
devices?: string[]
}
- lxc_ip?: string
lxc_ip_info?: {
all_ips: string[]
real_ips: string[]
@@ -139,7 +138,7 @@ const fetcher = async (url: string) => {
headers: {
"Content-Type": "application/json",
},
- signal: AbortSignal.timeout(30000),
+ signal: AbortSignal.timeout(60000),
})
if (!response.ok) {
@@ -283,15 +282,22 @@ export function VirtualMachines() {
const [editedNotes, setEditedNotes] = useState("")
const [savingNotes, setSavingNotes] = useState(false)
const [selectedMetric, setSelectedMetric] = useState
(null)
+ const [ipsLoaded, setIpsLoaded] = useState(false)
+ const [loadingIPs, setLoadingIPs] = useState(false)
useEffect(() => {
const fetchLXCIPs = async () => {
- if (!vmData) return
+ // Only fetch if data exists, not already loaded, and not currently loading
+ if (!vmData || ipsLoaded || loadingIPs) return
const lxcs = vmData.filter((vm) => vm.type === "lxc")
- if (lxcs.length === 0) return
+ if (lxcs.length === 0) {
+ setIpsLoaded(true)
+ return
+ }
+ setLoadingIPs(true)
const configs: Record = {}
const batchSize = 5
@@ -320,16 +326,20 @@ export function VirtualMachines() {
}
} catch (error) {
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
+ configs[lxc.vmid] = "N/A"
}
}),
)
setVmConfigs((prev) => ({ ...prev, ...configs }))
}
+
+ setLoadingIPs(false)
+ setIpsLoaded(true)
}
fetchLXCIPs()
- }, [vmData])
+ }, [vmData, ipsLoaded, loadingIPs])
const handleVMClick = async (vm: VMData) => {
setSelectedVM(vm)
@@ -469,7 +479,7 @@ export function VirtualMachines() {
"/api/system",
fetcher,
{
- refreshInterval: 23000,
+ refreshInterval: 37000,
revalidateOnFocus: false,
},
)
@@ -1068,6 +1078,7 @@ export function VirtualMachines() {
<>
+ {/* Desktop layout: Uptime now appears after status badge */}
@@ -1084,15 +1095,16 @@ export function VirtualMachines() {
{selectedVM.status.toUpperCase()}
+ {selectedVM.status === "running" && (
+
+ Uptime: {formatUptime(selectedVM.uptime)}
+
+ )}
- {selectedVM.status === "running" && (
-
- Uptime: {formatUptime(selectedVM.uptime)}
-
- )}
>
)}
+ {/* Mobile layout unchanged */}
@@ -1117,9 +1129,6 @@ export function VirtualMachines() {
)}
-
- View and manage configuration, resources, and status for this virtual machine
-
diff --git a/AppImage/lib/api-config.ts b/AppImage/lib/api-config.ts
index f5aea73..8ee2ff6 100644
--- a/AppImage/lib/api-config.ts
+++ b/AppImage/lib/api-config.ts
@@ -3,6 +3,14 @@
* Handles API URL generation with automatic proxy detection
*/
+/**
+ * API Server Port Configuration
+ * Default: 8008 (production)
+ * Can be changed to 8009 for beta testing
+ * This can also be set via NEXT_PUBLIC_API_PORT environment variable
+ */
+export const API_PORT = process.env.NEXT_PUBLIC_API_PORT || "8008"
+
/**
* Gets the base URL for API calls
* Automatically detects if running behind a proxy by checking if we're on a standard port
@@ -30,8 +38,8 @@ export function getApiBaseUrl(): string {
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
return ""
} else {
- // Direct access - use explicit port 8008
- const baseUrl = `${protocol}//${hostname}:8008`
+ // Direct access - use explicit API port
+ const baseUrl = `${protocol}//${hostname}:${API_PORT}`
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
return baseUrl
}
diff --git a/AppImage/package.json b/AppImage/package.json
index d2bcac5..af6ae56 100644
--- a/AppImage/package.json
+++ b/AppImage/package.json
@@ -1,5 +1,5 @@
{
- "name": "proxmenux-monitor",
+ "name": "ProxMenux-Monitor",
"version": "1.0.1",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py
index 1889cd4..df32e82 100644
--- a/AppImage/scripts/health_monitor.py
+++ b/AppImage/scripts/health_monitor.py
@@ -64,8 +64,8 @@ class HealthMonitor:
LOG_CHECK_INTERVAL = 300
# Updates Thresholds
- UPDATES_WARNING = 10
- UPDATES_CRITICAL = 30
+ UPDATES_WARNING = 365 # Only warn after 1 year without updates
+ UPDATES_CRITICAL = 730 # Critical after 2 years
# Known benign errors from Proxmox that should not trigger alerts
BENIGN_ERROR_PATTERNS = [
@@ -1376,7 +1376,8 @@ class HealthMonitor:
def _check_updates(self) -> Optional[Dict[str, Any]]:
"""
Check for pending system updates with intelligence.
- Only warns for: critical security updates, kernel updates, or updates pending >30 days.
+ Now only warns after 365 days without updates.
+ Critical security updates and kernel updates trigger INFO status immediately.
"""
cache_key = 'updates_check'
current_time = time.time()
@@ -1386,6 +1387,17 @@ class HealthMonitor:
return self.cached_results.get(cache_key)
try:
+ apt_history_path = '/var/log/apt/history.log'
+ last_update_days = None
+
+ if os.path.exists(apt_history_path):
+ try:
+ mtime = os.path.getmtime(apt_history_path)
+ days_since_update = (current_time - mtime) / 86400
+ last_update_days = int(days_since_update)
+ except Exception:
+ pass
+
result = subprocess.run(
['apt-get', 'upgrade', '--dry-run'],
capture_output=True,
@@ -1419,8 +1431,38 @@ class HealthMonitor:
if security_updates:
status = 'WARNING'
reason = f'{len(security_updates)} security update(s) available'
+ # Record persistent error for security updates
+ health_persistence.record_error(
+ error_key='updates_security',
+ category='updates',
+ severity='WARNING',
+ reason=reason,
+ details={'count': len(security_updates), 'packages': security_updates[:5]}
+ )
+ elif last_update_days and last_update_days >= 730:
+ # 2+ years without updates - CRITICAL
+ status = 'CRITICAL'
+ reason = f'System not updated in {last_update_days} days (>2 years)'
+ health_persistence.record_error(
+ error_key='updates_730days',
+ category='updates',
+ severity='CRITICAL',
+ reason=reason,
+ details={'days': last_update_days, 'update_count': update_count}
+ )
+ elif last_update_days and last_update_days >= 365:
+ # 1+ year without updates - WARNING
+ status = 'WARNING'
+ reason = f'System not updated in {last_update_days} days (>1 year)'
+ health_persistence.record_error(
+ error_key='updates_365days',
+ category='updates',
+ severity='WARNING',
+ reason=reason,
+ details={'days': last_update_days, 'update_count': update_count}
+ )
elif kernel_updates:
- status = 'INFO' # Informational, not critical
+ status = 'INFO'
reason = f'{len(kernel_updates)} kernel/PVE update(s) available'
elif update_count > 50:
status = 'INFO'
@@ -1435,6 +1477,8 @@ class HealthMonitor:
}
if reason:
update_result['reason'] = reason
+ if last_update_days:
+ update_result['days_since_update'] = last_update_days
self.cached_results[cache_key] = update_result
self.last_check_times[cache_key] = current_time
diff --git a/AppImage/scripts/health_persistence.py b/AppImage/scripts/health_persistence.py
index 51b4510..639d108 100644
--- a/AppImage/scripts/health_persistence.py
+++ b/AppImage/scripts/health_persistence.py
@@ -27,6 +27,7 @@ class HealthPersistence:
VM_ERROR_RETENTION = 48 * 3600 # 48 hours
LOG_ERROR_RETENTION = 24 * 3600 # 24 hours
DISK_ERROR_RETENTION = 48 * 3600 # 48 hours
+ UPDATES_SUPPRESSION = 180 * 24 * 3600 # 180 days (6 months)
def __init__(self):
"""Initialize persistence with database in config directory"""
@@ -102,8 +103,15 @@ class HealthPersistence:
resolved_dt = datetime.fromisoformat(ack_check[1])
hours_since_ack = (datetime.now() - resolved_dt).total_seconds() / 3600
- if hours_since_ack < 24:
- # Skip re-adding recently acknowledged errors (within 24h)
+ if category == 'updates':
+ # Updates: suppress for 180 days (6 months)
+ suppression_hours = self.UPDATES_SUPPRESSION / 3600
+ else:
+ # Other errors: suppress for 24 hours
+ suppression_hours = 24
+
+ if hours_since_ack < suppression_hours:
+ # Skip re-adding recently acknowledged errors
conn.close()
return {'type': 'skipped_acknowledged', 'needs_notification': False}
except Exception:
diff --git a/scripts/test/ProxMenux-1.0.1-beta2.AppImage b/scripts/test/ProxMenux-1.0.1-beta2.AppImage
new file mode 100755
index 0000000..2b52bf4
Binary files /dev/null and b/scripts/test/ProxMenux-1.0.1-beta2.AppImage differ