diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 15db5a0..8bb8e9d 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -21,6 +21,7 @@ import { 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" const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -247,7 +248,7 @@ export default function Hardware() { const apiUrl = isStandardPort ? `/api/gpu/${fullSlot}/realtime` - : `${protocol}//${hostname}:8008/api/gpu/${fullSlot}/realtime` + : `${protocol}//${hostname}:${API_PORT}/api/gpu/${fullSlot}/realtime` const response = await fetch(apiUrl, { method: "GET", diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 7a09397..3bdb0c8 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -92,6 +92,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const data = await response.json() console.log("[v0] Health data received:", data) setHealthData(data) + + const event = new CustomEvent("healthStatusUpdated", { + detail: { status: data.overall }, + }) + window.dispatchEvent(event) } catch (err) { console.error("[v0] Error fetching health data:", err) setError(err instanceof Error ? err.message : "Unknown error") @@ -275,7 +280,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu onClick={() => handleCategoryClick(key, status)} className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${ status === "OK" - ? "bg-green-500/5 border-green-500/20 hover:bg-green-500/10" + ? "bg-card border-border hover:bg-muted/30" : status === "WARNING" ? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer" : status === "CRITICAL" @@ -284,7 +289,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu }`} >
- + {getStatusIcon(status)}
@@ -294,7 +299,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu variant="outline" className={`shrink-0 text-xs ${ status === "OK" - ? "border-green-500 text-green-500 bg-green-500/5" + ? "border-green-500 text-green-500 bg-transparent" : status === "WARNING" ? "border-yellow-500 text-yellow-500 bg-yellow-500/5" : status === "CRITICAL" @@ -321,7 +326,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {detailValue.reason} )}
- {status !== "OK" && ( + {(status === "WARNING" || status === "CRITICAL") && ( + +
+
+
+ +
+ +
+ +
+
+
+ +
+
+

+ What's New in Version {APP_VERSION} +

+

+ We've added exciting new features and improvements to make ProxMenux Monitor even better! +

+
+ +
+ {CURRENT_VERSION_FEATURES.map((feature, index) => ( +
+
{feature.icon}
+

{feature.text}

+
+ ))} +
+
+ +
+
+ + +
+ setDontShowAgain(checked as boolean)} + /> + +
+
+
+
+ + + ) +} + +export function useVersionCheck() { + const [showReleaseNotes, setShowReleaseNotes] = useState(false) + + useEffect(() => { + const lastSeenVersion = localStorage.getItem("proxmenux-last-seen-version") + + if (lastSeenVersion !== APP_VERSION) { + setShowReleaseNotes(true) + } + }, []) + + return { showReleaseNotes, setShowReleaseNotes } +} + +export { APP_VERSION } diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 3cc86ce..bbc7cb1 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -6,6 +6,7 @@ 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 { APP_VERSION } from "./release-notes-modal" import { getApiUrl } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" @@ -40,6 +41,9 @@ export function Settings() { const [proxmenuxTools, setProxmenuxTools] = useState([]) const [loadingTools, setLoadingTools] = useState(true) + const [expandedVersions, setExpandedVersions] = useState>({ + [APP_VERSION]: true, // Current version expanded by default + }) useEffect(() => { checkAuthStatus() @@ -274,6 +278,13 @@ export function Settings() { window.location.reload() } + const toggleVersion = (version: string) => { + setExpandedVersions((prev) => ({ + ...prev, + [version]: !prev[version], + })) + } + return (
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