Merge branch 'MacRimi:main' into main

This commit is contained in:
cod378
2025-11-11 23:39:28 -03:00
committed by GitHub
17 changed files with 471 additions and 137 deletions

View File

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

View File

@@ -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
}`}
>
<div className="mt-0.5 flex-shrink-0 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4 text-blue-500" />
{getStatusIcon(status)}
</div>
<div className="flex-1 min-w-0">
@@ -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
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
)}
</div>
{status !== "OK" && (
{(status === "WARNING" || status === "CRITICAL") && (
<Button
size="sm"
variant="outline"

View File

@@ -5,6 +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"
interface MetricsViewProps {
vmid: number
@@ -121,7 +122,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:8008`
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}`
const apiUrl = `${baseUrl}/api/vms/${vmid}/metrics?timeframe=${timeframe}`

View File

@@ -3,6 +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"
interface NetworkMetricsData {
time: string
@@ -78,7 +79,7 @@ export function NetworkTrafficChart({
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:8008`
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}`
const apiUrl = interfaceName
? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}`

View File

@@ -6,6 +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"
const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" },
@@ -91,7 +92,7 @@ export function NodeMetricsCharts() {
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:8008`
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}`
const apiUrl = `${baseUrl}/api/node/metrics?timeframe=${timeframe}`

View File

@@ -17,10 +17,6 @@ import {
Cpu,
FileText,
Rocket,
Zap,
Shield,
Link2,
Gauge,
} from "lucide-react"
import Image from "next/image"
import { Checkbox } from "./ui/checkbox"
@@ -32,7 +28,6 @@ interface OnboardingSlide {
image?: string
icon: React.ReactNode
gradient: string
features?: { icon: React.ReactNode; text: string }[]
}
const slides: OnboardingSlide[] = [
@@ -46,35 +41,6 @@ const slides: OnboardingSlide[] = [
},
{
id: 1,
title: "What's New in This Version",
description: "We've added exciting new features and improvements to make ProxMenux Monitor even better!",
icon: <Zap className="h-16 w-16" />,
gradient: "from-amber-500 via-orange-500 to-red-500",
features: [
{
icon: <Link2 className="h-5 w-5" />,
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
},
{
icon: <Shield className="h-5 w-5" />,
text: "Authentication System - Secure your dashboard with password protection",
},
{
icon: <Gauge className="h-5 w-5" />,
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
},
{
icon: <HardDrive className="h-5 w-5" />,
text: "Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
},
{
icon: <Network className="h-5 w-5" />,
text: "SATA/SAS Information - View detailed interface information for all storage devices",
},
],
},
{
id: 2,
title: "System Overview",
description:
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
@@ -83,7 +49,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-cyan-500",
},
{
id: 3,
id: 2,
title: "Storage Management",
description:
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
@@ -92,7 +58,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-cyan-500 to-teal-500",
},
{
id: 4,
id: 3,
title: "Network Metrics",
description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
@@ -101,7 +67,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-teal-500 to-green-500",
},
{
id: 5,
id: 4,
title: "Virtual Machines & Containers",
description:
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
@@ -110,7 +76,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-green-500 to-emerald-500",
},
{
id: 6,
id: 5,
title: "Hardware Information",
description:
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
@@ -119,7 +85,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-emerald-500 to-blue-500",
},
{
id: 7,
id: 6,
title: "System Logs",
description:
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
@@ -128,7 +94,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-indigo-500",
},
{
id: 8,
id: 7,
title: "Ready for the Future!",
description:
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
@@ -194,7 +160,6 @@ export function OnboardingCarousel() {
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
{/* Close button */}
<Button
variant="ghost"
size="icon"
@@ -210,7 +175,6 @@ export function OnboardingCarousel() {
<div className="absolute inset-0 bg-black/10" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
{/* Icon or Image */}
<div className="relative z-10 text-white">
{slide.image ? (
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
@@ -236,7 +200,6 @@ export function OnboardingCarousel() {
)}
</div>
{/* Decorative elements */}
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
</div>
@@ -249,21 +212,6 @@ export function OnboardingCarousel() {
</p>
</div>
{slide.features && (
<div className="space-y-2 md:space-y-3 py-2">
{slide.features.map((feature, index) => (
<div
key={index}
className="flex items-start gap-2 md:gap-3 p-2 md:p-3 rounded-lg bg-muted/50 border border-border/50"
>
<div className="text-blue-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
</div>
))}
</div>
)}
{/* Progress dots */}
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
{slides.map((_, index) => (
<button

View File

@@ -13,6 +13,7 @@ import { SystemLogs } from "./system-logs"
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 {
RefreshCw,
@@ -76,6 +77,7 @@ export function ProxmoxDashboard() {
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
const fetchSystemData = useCallback(async () => {
const apiUrl = getApiUrl("/api/system-info")
@@ -164,6 +166,31 @@ export function ProxmoxDashboard() {
}
}, [])
useEffect(() => {
const handleHealthStatusUpdate = (event: CustomEvent) => {
const { status } = event.detail
let healthStatus: "healthy" | "warning" | "critical"
if (status === "CRITICAL") {
healthStatus = "critical"
} else if (status === "WARNING") {
healthStatus = "warning"
} else {
healthStatus = "healthy"
}
setSystemStatus((prev) => ({
...prev,
status: healthStatus,
}))
}
window.addEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
return () => {
window.removeEventListener("healthStatusUpdated", handleHealthStatusUpdate as EventListener)
}
}, [])
useEffect(() => {
if (
systemStatus.serverName &&
@@ -258,6 +285,7 @@ export function ProxmoxDashboard() {
return (
<div className="min-h-screen bg-background">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
{!isServerConnected && (
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">

View File

@@ -0,0 +1,203 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent } from "./ui/dialog"
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
const APP_VERSION = "1.0.1" // Sync with AppImage/package.json
interface ReleaseNote {
date: string
changes: {
added?: string[]
changed?: string[]
fixed?: string[]
}
}
export const CHANGELOG: Record<string, ReleaseNote> = {
"1.0.1": {
date: "November 11, 2025",
changes: {
added: [
"Proxy Support - Access ProxMenux through reverse proxies with full functionality",
"Authentication System - Secure your dashboard with password protection",
"PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
"Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
"SATA/SAS Information - View detailed interface information for all storage devices",
"Two-Factor Authentication (2FA) - Enhanced security with TOTP support",
"Health Monitoring System - Comprehensive system health checks with dismissible warnings",
"Release Notes Modal - Automatic notification of new features and improvements",
],
changed: [
"Optimized VM & LXC page - Reduced CPU usage by 85% through intelligent caching",
"Storage metrics now separate local and remote storage for clarity",
"Update warnings now appear only after 365 days instead of 30 days",
"API intervals staggered to distribute server load (23s and 37s)",
],
fixed: [
"Fixed dark mode text contrast issues in various components",
"Corrected storage calculation discrepancies between Overview and Storage pages",
"Resolved JSON stringify error in VM control actions",
"Improved IP address fetching for LXC containers",
],
},
},
"1.0.0": {
date: "October 15, 2025",
changes: {
added: [
"Initial release of ProxMenux Monitor",
"Real-time system monitoring dashboard",
"Storage management with SMART health monitoring",
"Network metrics and bandwidth tracking",
"VM & LXC container management",
"Hardware information display",
"System logs viewer with filtering",
],
},
},
}
const CURRENT_VERSION_FEATURES = [
{
icon: <Link2 className="h-5 w-5" />,
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
},
{
icon: <Shield className="h-5 w-5" />,
text: "Two-Factor Authentication (2FA) - Enhanced security with TOTP support for login protection",
},
{
icon: <Zap className="h-5 w-5" />,
text: "Performance Improvements - Optimized loading times and reduced CPU usage across the application",
},
{
icon: <HardDrive className="h-5 w-5" />,
text: "Storage Enhancements - Improved disk space consumption display with local and remote storage separation",
},
{
icon: <Gauge className="h-5 w-5" />,
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and identify performance bottlenecks",
},
{
icon: <Wrench className="h-5 w-5" />,
text: "Hardware Page Improvements - Enhanced hardware information display with detailed PCIe and interface data",
},
{
icon: <Settings className="h-5 w-5" />,
text: "New Settings Page - Centralized configuration for authentication, optimizations, and system preferences",
},
]
interface ReleaseNotesModalProps {
open: boolean
onClose: () => void
}
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
const [dontShowAgain, setDontShowAgain] = useState(false)
const handleClose = () => {
if (dontShowAgain) {
localStorage.setItem("proxmenux-last-seen-version", APP_VERSION)
}
onClose()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
<div className="relative h-32 md:h-40 bg-gradient-to-br from-amber-500 via-orange-500 to-red-500 flex items-center justify-center overflow-hidden flex-shrink-0">
<div className="absolute inset-0 bg-black/10" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(255,255,255,0.1),transparent)]" />
<div className="relative z-10 text-white animate-pulse">
<Sparkles className="h-12 w-12 md:h-14 md:w-14" />
</div>
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
</div>
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
<div className="space-y-2">
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
What's New in Version {APP_VERSION}
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
We've added exciting new features and improvements to make ProxMenux Monitor even better!
</p>
</div>
<div className="space-y-2">
{CURRENT_VERSION_FEATURES.map((feature, index) => (
<div
key={index}
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
>
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
</div>
))}
</div>
</div>
<div className="flex-shrink-0 p-6 md:p-8 pt-4 border-t border-border/50 bg-card">
<div className="flex flex-col gap-3">
<Button
onClick={handleClose}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
>
<Sparkles className="h-4 w-4 mr-2" />
Got it!
</Button>
<div className="flex items-center justify-center gap-2">
<Checkbox
id="dont-show-version-again"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
/>
<label
htmlFor="dont-show-version-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
>
Don't show again for this version
</label>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
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 }

View File

@@ -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<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true)
const [expandedVersions, setExpandedVersions] = useState<Record<string, boolean>>({
[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 (
<div className="space-y-6">
<div>

View File

@@ -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() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Used Storage</CardTitle>
<CardTitle className="text-sm font-medium">Local Used</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalProxmoxUsed)}</div>
<p className="text-xs text-muted-foreground mt-1">{usagePercent}% used</p>
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalLocalUsed)}</div>
<p className="text-xs mt-1">
<span className={getUsageColor(Number.parseFloat(localUsagePercent))}>{localUsagePercent}%</span>
<span className="text-muted-foreground"> of </span>
<span className="text-green-500">{formatStorage(totalLocalCapacity)}</span>
</p>
</CardContent>
</Card>
{/* Disk Health */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Health</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">Remote Used</CardTitle>
<Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
<div className="text-xl lg:text-2xl font-bold">
{remoteStorageCount > 0 ? formatStorage(totalRemoteUsed) : "None"}
</div>
<p className="text-xs mt-1">
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
{diskHealthBreakdown.warning > 0 && (
{remoteStorageCount > 0 ? (
<>
{", "}
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
</>
)}
{diskHealthBreakdown.critical > 0 && (
<>
{", "}
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
<span className={getUsageColor(Number.parseFloat(remoteUsagePercent))}>{remoteUsagePercent}%</span>
<span className="text-muted-foreground"> of </span>
<span className="text-green-500">{formatStorage(totalRemoteCapacity)}</span>
</>
) : (
<span className="text-muted-foreground">No remote storage</span>
)}
</p>
</CardContent>
</Card>
{/* Disk Types */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Types</CardTitle>
<CardTitle className="text-sm font-medium">Physical Disks</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
<p className="text-xs mt-1">
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
{diskTypesBreakdown.ssd > 0 && (
<>
{diskTypesBreakdown.nvme > 0 && ", "}
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
</>
)}
{diskTypesBreakdown.hdd > 0 && (
<>
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
</>
)}
</p>
<div className="space-y-1 mt-1">
<p className="text-xs">
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
{diskTypesBreakdown.ssd > 0 && (
<>
{diskTypesBreakdown.nvme > 0 && ", "}
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
</>
)}
{diskTypesBreakdown.hdd > 0 && (
<>
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
</>
)}
</p>
<p className="text-xs">
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
{diskHealthBreakdown.warning > 0 && (
<>
{", "}
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
</>
)}
{diskHealthBreakdown.critical > 0 && (
<>
{", "}
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
</>
)}
</p>
</div>
</CardContent>
</Card>
</div>
@@ -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) => (

View File

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

View File

@@ -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<string | null>(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<number, string> = {}
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() {
<>
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
<DialogTitle className="flex flex-col gap-3">
{/* Desktop layout: Uptime now appears after status badge */}
<div className="hidden sm:flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 flex-shrink-0" />
@@ -1084,15 +1095,16 @@ export function VirtualMachines() {
<Badge variant="outline" className={`${getStatusColor(selectedVM.status)} flex-shrink-0`}>
{selectedVM.status.toUpperCase()}
</Badge>
{selectedVM.status === "running" && (
<span className="text-sm text-muted-foreground">
Uptime: {formatUptime(selectedVM.uptime)}
</span>
)}
</div>
{selectedVM.status === "running" && (
<span className="text-sm text-muted-foreground ml-auto">
Uptime: {formatUptime(selectedVM.uptime)}
</span>
)}
</>
)}
</div>
{/* Mobile layout unchanged */}
<div className="sm:hidden flex flex-col gap-2">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 flex-shrink-0" />
@@ -1117,9 +1129,6 @@ export function VirtualMachines() {
)}
</div>
</DialogTitle>
<DialogDescription>
View and manage configuration, resources, and status for this virtual machine
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">

View File

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

View File

@@ -1,5 +1,5 @@
{
"name": "proxmenux-monitor",
"name": "ProxMenux-Monitor",
"version": "1.0.1",
"description": "Proxmox System Monitoring Dashboard",
"private": true,

View File

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

View File

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

Binary file not shown.