mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-17 19:16:25 +00:00
Merge branch 'MacRimi:main' into main
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
|||||||
import useSWR from "swr"
|
import useSWR from "swr"
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { type HardwareData, type GPU, type PCIDevice, type StorageDevice, fetcher } from "../types/hardware"
|
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 => {
|
const parseLsblkSize = (sizeStr: string | undefined): number => {
|
||||||
if (!sizeStr) return 0
|
if (!sizeStr) return 0
|
||||||
@@ -247,7 +248,7 @@ export default function Hardware() {
|
|||||||
|
|
||||||
const apiUrl = isStandardPort
|
const apiUrl = isStandardPort
|
||||||
? `/api/gpu/${fullSlot}/realtime`
|
? `/api/gpu/${fullSlot}/realtime`
|
||||||
: `${protocol}//${hostname}:8008/api/gpu/${fullSlot}/realtime`
|
: `${protocol}//${hostname}:${API_PORT}/api/gpu/${fullSlot}/realtime`
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log("[v0] Health data received:", data)
|
console.log("[v0] Health data received:", data)
|
||||||
setHealthData(data)
|
setHealthData(data)
|
||||||
|
|
||||||
|
const event = new CustomEvent("healthStatusUpdated", {
|
||||||
|
detail: { status: data.overall },
|
||||||
|
})
|
||||||
|
window.dispatchEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[v0] Error fetching health data:", err)
|
console.error("[v0] Error fetching health data:", err)
|
||||||
setError(err instanceof Error ? err.message : "Unknown error")
|
setError(err instanceof Error ? err.message : "Unknown error")
|
||||||
@@ -275,7 +280,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
onClick={() => handleCategoryClick(key, status)}
|
onClick={() => handleCategoryClick(key, status)}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
status === "OK"
|
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"
|
: status === "WARNING"
|
||||||
? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
|
||||||
: status === "CRITICAL"
|
: 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">
|
<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)}
|
{getStatusIcon(status)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -294,7 +299,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={`shrink-0 text-xs ${
|
className={`shrink-0 text-xs ${
|
||||||
status === "OK"
|
status === "OK"
|
||||||
? "border-green-500 text-green-500 bg-green-500/5"
|
? "border-green-500 text-green-500 bg-transparent"
|
||||||
: status === "WARNING"
|
: status === "WARNING"
|
||||||
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
|
||||||
: status === "CRITICAL"
|
: status === "CRITICAL"
|
||||||
@@ -321,7 +326,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{status !== "OK" && (
|
{(status === "WARNING" || status === "CRITICAL") && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||||
|
import { API_PORT } from "@/lib/api-config"
|
||||||
|
|
||||||
interface MetricsViewProps {
|
interface MetricsViewProps {
|
||||||
vmid: number
|
vmid: number
|
||||||
@@ -121,7 +122,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
const { protocol, hostname, port } = window.location
|
const { protocol, hostname, port } = window.location
|
||||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
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}`
|
const apiUrl = `${baseUrl}/api/vms/${vmid}/metrics?timeframe=${timeframe}`
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { API_PORT } from "@/lib/api-config"
|
||||||
|
|
||||||
interface NetworkMetricsData {
|
interface NetworkMetricsData {
|
||||||
time: string
|
time: string
|
||||||
@@ -78,7 +79,7 @@ export function NetworkTrafficChart({
|
|||||||
const { protocol, hostname, port } = window.location
|
const { protocol, hostname, port } = window.location
|
||||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||||
|
|
||||||
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:8008`
|
const baseUrl = isStandardPort ? "" : `${protocol}//${hostname}:${API_PORT}`
|
||||||
|
|
||||||
const apiUrl = interfaceName
|
const apiUrl = interfaceName
|
||||||
? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
|
import { API_PORT } from "@/lib/api-config"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", label: "1 Hour" },
|
||||||
@@ -91,7 +92,7 @@ export function NodeMetricsCharts() {
|
|||||||
const { protocol, hostname, port } = window.location
|
const { protocol, hostname, port } = window.location
|
||||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
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}`
|
const apiUrl = `${baseUrl}/api/node/metrics?timeframe=${timeframe}`
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
FileText,
|
FileText,
|
||||||
Rocket,
|
Rocket,
|
||||||
Zap,
|
|
||||||
Shield,
|
|
||||||
Link2,
|
|
||||||
Gauge,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
@@ -32,7 +28,6 @@ interface OnboardingSlide {
|
|||||||
image?: string
|
image?: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
gradient: string
|
gradient: string
|
||||||
features?: { icon: React.ReactNode; text: string }[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const slides: OnboardingSlide[] = [
|
const slides: OnboardingSlide[] = [
|
||||||
@@ -46,35 +41,6 @@ const slides: OnboardingSlide[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
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",
|
title: "System Overview",
|
||||||
description:
|
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.",
|
"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",
|
gradient: "from-blue-500 to-cyan-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 2,
|
||||||
title: "Storage Management",
|
title: "Storage Management",
|
||||||
description:
|
description:
|
||||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
"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",
|
gradient: "from-cyan-500 to-teal-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 3,
|
||||||
title: "Network Metrics",
|
title: "Network Metrics",
|
||||||
description:
|
description:
|
||||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
"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",
|
gradient: "from-teal-500 to-green-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 4,
|
||||||
title: "Virtual Machines & Containers",
|
title: "Virtual Machines & Containers",
|
||||||
description:
|
description:
|
||||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
"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",
|
gradient: "from-green-500 to-emerald-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 5,
|
||||||
title: "Hardware Information",
|
title: "Hardware Information",
|
||||||
description:
|
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.",
|
"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",
|
gradient: "from-emerald-500 to-blue-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 6,
|
||||||
title: "System Logs",
|
title: "System Logs",
|
||||||
description:
|
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.",
|
"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",
|
gradient: "from-blue-500 to-indigo-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 7,
|
||||||
title: "Ready for the Future!",
|
title: "Ready for the Future!",
|
||||||
description:
|
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.",
|
"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}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
<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">
|
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||||
{/* Close button */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -210,7 +175,6 @@ export function OnboardingCarousel() {
|
|||||||
<div className="absolute inset-0 bg-black/10" />
|
<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="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">
|
<div className="relative z-10 text-white">
|
||||||
{slide.image ? (
|
{slide.image ? (
|
||||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Decorative elements */}
|
|
||||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
<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 className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
@@ -249,21 +212,6 @@ export function OnboardingCarousel() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
|
||||||
{slides.map((_, index) => (
|
{slides.map((_, index) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { SystemLogs } from "./system-logs"
|
|||||||
import { Settings } from "./settings"
|
import { Settings } from "./settings"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -76,6 +77,7 @@ export function ProxmoxDashboard() {
|
|||||||
const [showNavigation, setShowNavigation] = useState(true)
|
const [showNavigation, setShowNavigation] = useState(true)
|
||||||
const [lastScrollY, setLastScrollY] = useState(0)
|
const [lastScrollY, setLastScrollY] = useState(0)
|
||||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||||
|
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||||
|
|
||||||
const fetchSystemData = useCallback(async () => {
|
const fetchSystemData = useCallback(async () => {
|
||||||
const apiUrl = getApiUrl("/api/system-info")
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
systemStatus.serverName &&
|
systemStatus.serverName &&
|
||||||
@@ -258,6 +285,7 @@ export function ProxmoxDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<OnboardingCarousel />
|
<OnboardingCarousel />
|
||||||
|
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||||
|
|
||||||
{!isServerConnected && (
|
{!isServerConnected && (
|
||||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||||
|
|||||||
203
AppImage/components/release-notes-modal.tsx
Normal file
203
AppImage/components/release-notes-modal.tsx
Normal 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 }
|
||||||
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
|
|||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
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 } from "lucide-react"
|
||||||
|
import { APP_VERSION } from "./release-notes-modal"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
import { TwoFactorSetup } from "./two-factor-setup"
|
import { TwoFactorSetup } from "./two-factor-setup"
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ export function Settings() {
|
|||||||
|
|
||||||
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
||||||
const [loadingTools, setLoadingTools] = useState(true)
|
const [loadingTools, setLoadingTools] = useState(true)
|
||||||
|
const [expandedVersions, setExpandedVersions] = useState<Record<string, boolean>>({
|
||||||
|
[APP_VERSION]: true, // Current version expanded by default
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuthStatus()
|
checkAuthStatus()
|
||||||
@@ -274,6 +278,13 @@ export function Settings() {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleVersion = (version: string) => {
|
||||||
|
setExpandedVersions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[version]: !prev[version],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
@@ -394,10 +394,20 @@ export function StorageOverview() {
|
|||||||
return "[&>div]:bg-red-500"
|
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 diskHealthBreakdown = getDiskHealthBreakdown()
|
||||||
const diskTypesBreakdown = getDiskTypesBreakdown()
|
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
|
proxmoxStorage?.storage
|
||||||
.filter(
|
.filter(
|
||||||
(storage) =>
|
(storage) =>
|
||||||
@@ -406,11 +416,12 @@ export function StorageOverview() {
|
|||||||
storage.status === "active" &&
|
storage.status === "active" &&
|
||||||
storage.total > 0 &&
|
storage.total > 0 &&
|
||||||
storage.used >= 0 &&
|
storage.used >= 0 &&
|
||||||
storage.available >= 0,
|
storage.available >= 0 &&
|
||||||
|
localStorageTypes.includes(storage.type.toLowerCase()),
|
||||||
)
|
)
|
||||||
.reduce((sum, storage) => sum + storage.used, 0) || 0
|
.reduce((sum, storage) => sum + storage.used, 0) || 0
|
||||||
|
|
||||||
const totalProxmoxCapacity =
|
const totalLocalCapacity =
|
||||||
proxmoxStorage?.storage
|
proxmoxStorage?.storage
|
||||||
.filter(
|
.filter(
|
||||||
(storage) =>
|
(storage) =>
|
||||||
@@ -419,11 +430,52 @@ export function StorageOverview() {
|
|||||||
storage.status === "active" &&
|
storage.status === "active" &&
|
||||||
storage.total > 0 &&
|
storage.total > 0 &&
|
||||||
storage.used >= 0 &&
|
storage.used >= 0 &&
|
||||||
storage.available >= 0,
|
storage.available >= 0 &&
|
||||||
|
localStorageTypes.includes(storage.type.toLowerCase()),
|
||||||
)
|
)
|
||||||
.reduce((sum, storage) => sum + storage.total, 0) || 0
|
.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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -458,50 +510,51 @@ export function StorageOverview() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalProxmoxUsed)}</div>
|
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalLocalUsed)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{usagePercent}% used</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Disk Health */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Disk Health</CardTitle>
|
<CardTitle className="text-sm font-medium">Remote Used</CardTitle>
|
||||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<p className="text-xs mt-1">
|
||||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
{remoteStorageCount > 0 ? (
|
||||||
{diskHealthBreakdown.warning > 0 && (
|
|
||||||
<>
|
<>
|
||||||
{", "}
|
<span className={getUsageColor(Number.parseFloat(remoteUsagePercent))}>{remoteUsagePercent}%</span>
|
||||||
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
|
<span className="text-muted-foreground"> of </span>
|
||||||
</>
|
<span className="text-green-500">{formatStorage(totalRemoteCapacity)}</span>
|
||||||
)}
|
|
||||||
{diskHealthBreakdown.critical > 0 && (
|
|
||||||
<>
|
|
||||||
{", "}
|
|
||||||
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
|
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No remote storage</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Disk Types */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||||
<p className="text-xs mt-1">
|
<div className="space-y-1 mt-1">
|
||||||
|
<p className="text-xs">
|
||||||
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
||||||
{diskTypesBreakdown.ssd > 0 && (
|
{diskTypesBreakdown.ssd > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -516,6 +569,22 @@ export function StorageOverview() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,11 +602,7 @@ export function StorageOverview() {
|
|||||||
{proxmoxStorage.storage
|
{proxmoxStorage.storage
|
||||||
.filter(
|
.filter(
|
||||||
(storage) =>
|
(storage) =>
|
||||||
storage &&
|
storage && storage.name && storage.total > 0 && storage.used >= 0 && storage.available >= 0,
|
||||||
storage.name &&
|
|
||||||
storage.total > 0 &&
|
|
||||||
storage.used >= 0 && // Ensure used is not negative
|
|
||||||
storage.available >= 0, // Ensure available is not negative
|
|
||||||
)
|
)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map((storage) => (
|
.map((storage) => (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState, useEffect, useMemo } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
|
import { API_PORT } from "@/lib/api-config"
|
||||||
|
|
||||||
interface Log {
|
interface Log {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
@@ -131,10 +132,10 @@ export function SystemLogs() {
|
|||||||
if (isStandardPort) {
|
if (isStandardPort) {
|
||||||
return endpoint
|
return endpoint
|
||||||
} else {
|
} else {
|
||||||
return `${protocol}//${hostname}:8008${endpoint}`
|
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `http://localhost:8008${endpoint}`
|
return `${protocol}//${hostname}:${API_PORT}${endpoint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
|||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Progress } from "./ui/progress"
|
import { Progress } from "./ui/progress"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Play,
|
Play,
|
||||||
@@ -124,7 +124,6 @@ interface VMDetails extends VMData {
|
|||||||
gpu_passthrough?: string[]
|
gpu_passthrough?: string[]
|
||||||
devices?: string[]
|
devices?: string[]
|
||||||
}
|
}
|
||||||
lxc_ip?: string
|
|
||||||
lxc_ip_info?: {
|
lxc_ip_info?: {
|
||||||
all_ips: string[]
|
all_ips: string[]
|
||||||
real_ips: string[]
|
real_ips: string[]
|
||||||
@@ -139,7 +138,7 @@ const fetcher = async (url: string) => {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(60000),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -283,15 +282,22 @@ export function VirtualMachines() {
|
|||||||
const [editedNotes, setEditedNotes] = useState("")
|
const [editedNotes, setEditedNotes] = useState("")
|
||||||
const [savingNotes, setSavingNotes] = useState(false)
|
const [savingNotes, setSavingNotes] = useState(false)
|
||||||
const [selectedMetric, setSelectedMetric] = useState<string | null>(null)
|
const [selectedMetric, setSelectedMetric] = useState<string | null>(null)
|
||||||
|
const [ipsLoaded, setIpsLoaded] = useState(false)
|
||||||
|
const [loadingIPs, setLoadingIPs] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLXCIPs = async () => {
|
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")
|
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 configs: Record<number, string> = {}
|
||||||
|
|
||||||
const batchSize = 5
|
const batchSize = 5
|
||||||
@@ -320,16 +326,20 @@ export function VirtualMachines() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
|
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
|
||||||
|
configs[lxc.vmid] = "N/A"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
setVmConfigs((prev) => ({ ...prev, ...configs }))
|
setVmConfigs((prev) => ({ ...prev, ...configs }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoadingIPs(false)
|
||||||
|
setIpsLoaded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchLXCIPs()
|
fetchLXCIPs()
|
||||||
}, [vmData])
|
}, [vmData, ipsLoaded, loadingIPs])
|
||||||
|
|
||||||
const handleVMClick = async (vm: VMData) => {
|
const handleVMClick = async (vm: VMData) => {
|
||||||
setSelectedVM(vm)
|
setSelectedVM(vm)
|
||||||
@@ -469,7 +479,7 @@ export function VirtualMachines() {
|
|||||||
"/api/system",
|
"/api/system",
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
refreshInterval: 23000,
|
refreshInterval: 37000,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1068,6 +1078,7 @@ export function VirtualMachines() {
|
|||||||
<>
|
<>
|
||||||
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
|
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
|
||||||
<DialogTitle className="flex flex-col gap-3">
|
<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="hidden sm:flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Server className="h-5 w-5 flex-shrink-0" />
|
<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`}>
|
<Badge variant="outline" className={`${getStatusColor(selectedVM.status)} flex-shrink-0`}>
|
||||||
{selectedVM.status.toUpperCase()}
|
{selectedVM.status.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
|
||||||
{selectedVM.status === "running" && (
|
{selectedVM.status === "running" && (
|
||||||
<span className="text-sm text-muted-foreground ml-auto">
|
<span className="text-sm text-muted-foreground">
|
||||||
Uptime: {formatUptime(selectedVM.uptime)}
|
Uptime: {formatUptime(selectedVM.uptime)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile layout unchanged */}
|
||||||
<div className="sm:hidden flex flex-col gap-2">
|
<div className="sm:hidden flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Server className="h-5 w-5 flex-shrink-0" />
|
<Server className="h-5 w-5 flex-shrink-0" />
|
||||||
@@ -1117,9 +1129,6 @@ export function VirtualMachines() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
|
||||||
View and manage configuration, resources, and status for this virtual machine
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
|||||||
@@ -3,6 +3,14 @@
|
|||||||
* Handles API URL generation with automatic proxy detection
|
* 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
|
* Gets the base URL for API calls
|
||||||
* Automatically detects if running behind a proxy by checking if we're on a standard port
|
* 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")
|
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
|
||||||
return ""
|
return ""
|
||||||
} else {
|
} else {
|
||||||
// Direct access - use explicit port 8008
|
// Direct access - use explicit API port
|
||||||
const baseUrl = `${protocol}//${hostname}:8008`
|
const baseUrl = `${protocol}//${hostname}:${API_PORT}`
|
||||||
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
|
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
|
||||||
return baseUrl
|
return baseUrl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "proxmenux-monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ class HealthMonitor:
|
|||||||
LOG_CHECK_INTERVAL = 300
|
LOG_CHECK_INTERVAL = 300
|
||||||
|
|
||||||
# Updates Thresholds
|
# Updates Thresholds
|
||||||
UPDATES_WARNING = 10
|
UPDATES_WARNING = 365 # Only warn after 1 year without updates
|
||||||
UPDATES_CRITICAL = 30
|
UPDATES_CRITICAL = 730 # Critical after 2 years
|
||||||
|
|
||||||
# Known benign errors from Proxmox that should not trigger alerts
|
# Known benign errors from Proxmox that should not trigger alerts
|
||||||
BENIGN_ERROR_PATTERNS = [
|
BENIGN_ERROR_PATTERNS = [
|
||||||
@@ -1376,7 +1376,8 @@ class HealthMonitor:
|
|||||||
def _check_updates(self) -> Optional[Dict[str, Any]]:
|
def _check_updates(self) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Check for pending system updates with intelligence.
|
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'
|
cache_key = 'updates_check'
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
@@ -1386,6 +1387,17 @@ class HealthMonitor:
|
|||||||
return self.cached_results.get(cache_key)
|
return self.cached_results.get(cache_key)
|
||||||
|
|
||||||
try:
|
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(
|
result = subprocess.run(
|
||||||
['apt-get', 'upgrade', '--dry-run'],
|
['apt-get', 'upgrade', '--dry-run'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -1419,8 +1431,38 @@ class HealthMonitor:
|
|||||||
if security_updates:
|
if security_updates:
|
||||||
status = 'WARNING'
|
status = 'WARNING'
|
||||||
reason = f'{len(security_updates)} security update(s) available'
|
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:
|
elif kernel_updates:
|
||||||
status = 'INFO' # Informational, not critical
|
status = 'INFO'
|
||||||
reason = f'{len(kernel_updates)} kernel/PVE update(s) available'
|
reason = f'{len(kernel_updates)} kernel/PVE update(s) available'
|
||||||
elif update_count > 50:
|
elif update_count > 50:
|
||||||
status = 'INFO'
|
status = 'INFO'
|
||||||
@@ -1435,6 +1477,8 @@ class HealthMonitor:
|
|||||||
}
|
}
|
||||||
if reason:
|
if reason:
|
||||||
update_result['reason'] = 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.cached_results[cache_key] = update_result
|
||||||
self.last_check_times[cache_key] = current_time
|
self.last_check_times[cache_key] = current_time
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class HealthPersistence:
|
|||||||
VM_ERROR_RETENTION = 48 * 3600 # 48 hours
|
VM_ERROR_RETENTION = 48 * 3600 # 48 hours
|
||||||
LOG_ERROR_RETENTION = 24 * 3600 # 24 hours
|
LOG_ERROR_RETENTION = 24 * 3600 # 24 hours
|
||||||
DISK_ERROR_RETENTION = 48 * 3600 # 48 hours
|
DISK_ERROR_RETENTION = 48 * 3600 # 48 hours
|
||||||
|
UPDATES_SUPPRESSION = 180 * 24 * 3600 # 180 days (6 months)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize persistence with database in config directory"""
|
"""Initialize persistence with database in config directory"""
|
||||||
@@ -102,8 +103,15 @@ class HealthPersistence:
|
|||||||
resolved_dt = datetime.fromisoformat(ack_check[1])
|
resolved_dt = datetime.fromisoformat(ack_check[1])
|
||||||
hours_since_ack = (datetime.now() - resolved_dt).total_seconds() / 3600
|
hours_since_ack = (datetime.now() - resolved_dt).total_seconds() / 3600
|
||||||
|
|
||||||
if hours_since_ack < 24:
|
if category == 'updates':
|
||||||
# Skip re-adding recently acknowledged errors (within 24h)
|
# 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()
|
conn.close()
|
||||||
return {'type': 'skipped_acknowledged', 'needs_notification': False}
|
return {'type': 'skipped_acknowledged', 'needs_notification': False}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
BIN
scripts/test/ProxMenux-1.0.1-beta2.AppImage
Executable file
BIN
scripts/test/ProxMenux-1.0.1-beta2.AppImage
Executable file
Binary file not shown.
Reference in New Issue
Block a user