diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..91bed5e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,29 @@
+---
+name: Bug Report
+about: Reporta un problema en el proyecto
+title: "[BUG] Describe el problema"
+labels: bug
+assignees: 'MacRimi'
+---
+
+## Descripción
+Describe el error de forma clara y concisa.
+
+## Pasos para reproducir
+1. ...
+2. ...
+3. ...
+
+## Comportamiento esperado
+¿Qué debería ocurrir?
+
+## Capturas de pantalla (Obligatorio)
+Agrega imágenes para ayudar a entender el problema.
+
+## Entorno
+- Sistema operativo:
+- Versión del software:
+- Otros detalles relevantes:
+
+## Información adicional
+Agrega cualquier otro contexto sobre el problema aquí.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..9354ec1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Soporte General
+ url: https://github.com/MacRimi/ProxMenux/discussions
+ about: Si tu solicitud no es un bug ni un feature, usa las discusiones.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..68dd603
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,19 @@
+---
+name: Feature Request
+about: Sugiere una nueva funcionalidad o mejora
+title: "[FEATURE] Describe la propuesta"
+labels: enhancement
+assignees: 'MacRimi'
+---
+
+## Descripción
+Explica la funcionalidad que propones.
+
+## Motivación
+¿Por qué es importante esta mejora? ¿Qué problema resuelve?
+
+## Alternativas consideradas
+¿Hay otras soluciones que hayas pensado?
+
+## Información adicional
+Agrega cualquier detalle extra que ayude a entender la propuesta.
diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx
new file mode 100644
index 0000000..fd269c1
--- /dev/null
+++ b/AppImage/components/auth-setup.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Button } from "./ui/button"
+import { Dialog, DialogContent } from "./ui/dialog"
+import { Input } from "./ui/input"
+import { Label } from "./ui/label"
+import { Shield, Lock, User, AlertCircle } from "lucide-react"
+import { getApiUrl } from "../lib/api-config"
+
+interface AuthSetupProps {
+ onComplete: () => void
+}
+
+export function AuthSetup({ onComplete }: AuthSetupProps) {
+ const [open, setOpen] = useState(false)
+ const [step, setStep] = useState<"choice" | "setup">("choice")
+ const [username, setUsername] = useState("")
+ const [password, setPassword] = useState("")
+ const [confirmPassword, setConfirmPassword] = useState("")
+ const [error, setError] = useState("")
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ const checkOnboardingStatus = async () => {
+ try {
+ const response = await fetch(getApiUrl("/api/auth/status"))
+ const data = await response.json()
+
+ console.log("[v0] Auth status for modal check:", data)
+
+ // Show modal if auth is not configured and not declined
+ if (!data.auth_configured) {
+ setTimeout(() => setOpen(true), 500)
+ }
+ } catch (error) {
+ console.error("[v0] Failed to check auth status:", error)
+ // Fail-safe: show modal if we can't check status
+ setTimeout(() => setOpen(true), 500)
+ }
+ }
+
+ checkOnboardingStatus()
+ }, [])
+
+ const handleSkipAuth = async () => {
+ setLoading(true)
+ setError("")
+
+ try {
+ console.log("[v0] Skipping authentication setup...")
+ const response = await fetch(getApiUrl("/api/auth/skip"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ })
+
+ const data = await response.json()
+ console.log("[v0] Auth skip response:", data)
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to skip authentication")
+ }
+
+ console.log("[v0] Authentication skipped successfully")
+ localStorage.setItem("proxmenux-auth-declined", "true")
+ setOpen(false)
+ onComplete()
+ } catch (err) {
+ console.error("[v0] Auth skip error:", err)
+ setError(err instanceof Error ? err.message : "Failed to save preference")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleSetupAuth = async () => {
+ setError("")
+
+ if (!username || !password) {
+ setError("Please fill in all fields")
+ return
+ }
+
+ if (password !== confirmPassword) {
+ setError("Passwords do not match")
+ return
+ }
+
+ if (password.length < 6) {
+ setError("Password must be at least 6 characters")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ console.log("[v0] Setting up authentication...")
+ const response = await fetch(getApiUrl("/api/auth/setup"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username,
+ password,
+ }),
+ })
+
+ const data = await response.json()
+ console.log("[v0] Auth setup response:", data)
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to setup authentication")
+ }
+
+ if (data.token) {
+ localStorage.setItem("proxmenux-auth-token", data.token)
+ localStorage.removeItem("proxmenux-auth-declined")
+ console.log("[v0] Authentication setup successful")
+ }
+
+ setOpen(false)
+ onComplete()
+ } catch (err) {
+ console.error("[v0] Auth setup error:", err)
+ setError(err instanceof Error ? err.message : "Failed to setup authentication")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ {step === "choice" ? (
+
+
+
+
+
+
Protect Your Dashboard?
+
+ Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
+
+
+
+
+ setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
+
+ Yes, Setup Password
+
+
+ No, Continue Without Protection
+
+
+
+
You can always enable this later in Settings
+
+ ) : (
+
+
+
+
+
+
Setup Authentication
+
Create a username and password to protect your dashboard
+
+
+ {error && (
+
+ )}
+
+
+
+
Username
+
+
+ setUsername(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
Password
+
+
+ setPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
Confirm Password
+
+
+ setConfirmPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
+
+ {loading ? "Setting up..." : "Setup Authentication"}
+
+ setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
+ Back
+
+
+
+ )}
+
+
+ )
+}
diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx
index 2ff78e8..53e0b2a 100644
--- a/AppImage/components/hardware.tsx
+++ b/AppImage/components/hardware.tsx
@@ -64,9 +64,12 @@ const formatMemory = (memoryKB: number | string): string => {
return `${tb.toFixed(1)} TB`
}
- // Convert to GB if >= 1024 MB
if (mb >= 1024) {
const gb = mb / 1024
+ // If GB value is greater than 999, convert to TB
+ if (gb > 999) {
+ return `${(gb / 1024).toFixed(2)} TB`
+ }
return `${gb.toFixed(1)} GB`
}
@@ -168,6 +171,22 @@ export default function Hardware() {
refreshInterval: 5000,
})
+ useEffect(() => {
+ if (hardwareData?.storage_devices) {
+ console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)
+ hardwareData.storage_devices.forEach((device) => {
+ if (device.name.startsWith("nvme")) {
+ console.log(`[v0] NVMe device ${device.name}:`, {
+ pcie_gen: device.pcie_gen,
+ pcie_width: device.pcie_width,
+ pcie_max_gen: device.pcie_max_gen,
+ pcie_max_width: device.pcie_max_width,
+ })
+ }
+ })
+ }
+ }, [hardwareData])
+
const [selectedGPU, setSelectedGPU] = useState(null)
const [realtimeGPUData, setRealtimeGPUData] = useState(null)
const [detailsLoading, setDetailsLoading] = useState(false)
@@ -1658,6 +1677,61 @@ export default function Hardware() {
const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate)
+ const getLinkSpeedInfo = (device: StorageDevice) => {
+ // NVMe PCIe information
+ if (device.name.startsWith("nvme") && (device.pcie_gen || device.pcie_width)) {
+ const current = `${device.pcie_gen || ""} ${device.pcie_width || ""}`.trim()
+ const max =
+ device.pcie_max_gen && device.pcie_max_width
+ ? `${device.pcie_max_gen} ${device.pcie_max_width}`.trim()
+ : null
+
+ const isLowerSpeed = max && current !== max
+
+ return {
+ text: current || null,
+ maxText: max,
+ isWarning: isLowerSpeed,
+ color: isLowerSpeed ? "text-orange-500" : "text-blue-500",
+ }
+ }
+
+ // SATA information
+ if (device.sata_version) {
+ return {
+ text: device.sata_version,
+ maxText: null,
+ isWarning: false,
+ color: "text-blue-500",
+ }
+ }
+
+ // SAS information
+ if (device.sas_version || device.sas_speed) {
+ const text = [device.sas_version, device.sas_speed].filter(Boolean).join(" ")
+ return {
+ text: text || null,
+ maxText: null,
+ isWarning: false,
+ color: "text-blue-500",
+ }
+ }
+
+ // Generic link speed
+ if (device.link_speed) {
+ return {
+ text: device.link_speed,
+ maxText: null,
+ isWarning: false,
+ color: "text-blue-500",
+ }
+ }
+
+ return null
+ }
+
+ const linkSpeed = getLinkSpeedInfo(device)
+
return (
{device.model}
)}
+ {linkSpeed && (
+
+ {linkSpeed.text}
+ {linkSpeed.maxText && linkSpeed.isWarning && (
+ (max: {linkSpeed.maxText})
+ )}
+
+ )}
)
})}
@@ -1695,46 +1777,44 @@ export default function Hardware() {
{selectedDisk.name}
- {selectedDisk.name && (
-
-
Type
- {(() => {
- const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
- let diskType = "HDD"
+
+ Type
+ {(() => {
+ const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
+ let diskType = "HDD"
- if (diskName.startsWith("nvme")) {
- diskType = "NVMe"
- } else if (rotationRate !== undefined && rotationRate !== null) {
- const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
- if (rateNum === 0 || isNaN(rateNum)) {
- diskType = "SSD"
- }
- } else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
+ if (diskName.startsWith("nvme")) {
+ diskType = "NVMe"
+ } else if (rotationRate !== undefined && rotationRate !== null) {
+ const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
+ if (rateNum === 0 || isNaN(rateNum)) {
diskType = "SSD"
}
-
- const badgeStyles: Record = {
- NVMe: {
- className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
- label: "NVMe SSD",
- },
- SSD: {
- className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
- label: "SSD",
- },
- HDD: {
- className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
- label: "HDD",
- },
- }
- return badgeStyles[diskType]
+ } else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
+ diskType = "SSD"
}
- const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
- return {diskBadge.label}
- })()}
-
- )}
+ const badgeStyles: Record
= {
+ NVMe: {
+ className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
+ label: "NVMe SSD",
+ },
+ SSD: {
+ className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
+ label: "SSD",
+ },
+ HDD: {
+ className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
+ label: "HDD",
+ },
+ }
+ return badgeStyles[diskType]
+ }
+
+ const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
+ return {diskBadge.label}
+ })()}
+
{selectedDisk.size && (
@@ -1743,6 +1823,84 @@ export default function Hardware() {
)}
+
+
+ Interface Information
+
+
+
+ {/* NVMe PCIe Information */}
+ {selectedDisk.name.startsWith("nvme") && (
+ <>
+ {selectedDisk.pcie_gen || selectedDisk.pcie_width ? (
+ <>
+
+ Current Link Speed
+
+ {selectedDisk.pcie_gen || "PCIe"} {selectedDisk.pcie_width || ""}
+
+
+ {selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
+
+ Maximum Link Speed
+
+ {selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width}
+
+
+ )}
+ >
+ ) : (
+
+ PCIe Link Speed
+ Detecting...
+
+ )}
+ >
+ )}
+
+ {/* SATA Information */}
+ {!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
+
+ SATA Version
+ {selectedDisk.sata_version}
+
+ )}
+
+ {/* SAS Information */}
+ {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
+
+ SAS Version
+ {selectedDisk.sas_version}
+
+ )}
+ {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
+
+ SAS Speed
+ {selectedDisk.sas_speed}
+
+ )}
+
+ {/* Generic Link Speed - only show if no specific interface info */}
+ {!selectedDisk.name.startsWith("nvme") &&
+ selectedDisk.link_speed &&
+ !selectedDisk.pcie_gen &&
+ !selectedDisk.sata_version &&
+ !selectedDisk.sas_version && (
+
+ Link Speed
+ {selectedDisk.link_speed}
+
+ )}
+
{selectedDisk.model && (
Model
@@ -1806,13 +1964,6 @@ export default function Hardware() {
{selectedDisk.form_factor}
)}
-
- {selectedDisk.sata_version && (
-
- SATA Version
- {selectedDisk.sata_version}
-
- )}
)}
diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx
new file mode 100644
index 0000000..3496648
--- /dev/null
+++ b/AppImage/components/health-status-modal.tsx
@@ -0,0 +1,269 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Loader2, CheckCircle2, AlertTriangle, XCircle, Activity } from "lucide-react"
+
+interface HealthDetail {
+ status: string
+ reason?: string
+ [key: string]: any
+}
+
+interface HealthDetails {
+ overall: string
+ summary: string
+ details: {
+ [category: string]: HealthDetail | { [key: string]: HealthDetail }
+ }
+ timestamp: string
+}
+
+interface HealthStatusModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ getApiUrl: (path: string) => string
+}
+
+export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
+ const [loading, setLoading] = useState(true)
+ const [healthData, setHealthData] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (open) {
+ fetchHealthDetails()
+ }
+ }, [open])
+
+ const fetchHealthDetails = async () => {
+ setLoading(true)
+ setError(null)
+
+ try {
+ const response = await fetch(getApiUrl("/api/health/details"))
+ if (!response.ok) {
+ throw new Error("Failed to fetch health details")
+ }
+ const data = await response.json()
+ console.log("[v0] Health data received:", data)
+ setHealthData(data)
+ } catch (err) {
+ console.error("[v0] Error fetching health data:", err)
+ setError(err instanceof Error ? err.message : "Unknown error")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const getHealthStats = () => {
+ if (!healthData?.details) {
+ return { total: 0, healthy: 0, warnings: 0, critical: 0 }
+ }
+
+ let healthy = 0
+ let warnings = 0
+ let critical = 0
+ let total = 0
+
+ const countStatus = (detail: any) => {
+ if (detail && typeof detail === "object" && detail.status) {
+ total++
+ const status = detail.status.toUpperCase()
+ if (status === "OK") healthy++
+ else if (status === "WARNING") warnings++
+ else if (status === "CRITICAL") critical++
+ }
+ }
+
+ Object.values(healthData.details).forEach((categoryData) => {
+ if (categoryData && typeof categoryData === "object") {
+ if ("status" in categoryData) {
+ countStatus(categoryData)
+ } else {
+ Object.values(categoryData).forEach(countStatus)
+ }
+ }
+ })
+
+ return { total, healthy, warnings, critical }
+ }
+
+ const getGroupedChecks = () => {
+ if (!healthData?.details) return {}
+
+ const grouped: { [key: string]: Array<{ name: string; status: string; reason?: string; details?: any }> } = {}
+
+ Object.entries(healthData.details).forEach(([category, categoryData]) => {
+ if (!categoryData || typeof categoryData !== "object") return
+
+ const categoryName = category.charAt(0).toUpperCase() + category.slice(1)
+ grouped[categoryName] = []
+
+ if ("status" in categoryData) {
+ grouped[categoryName].push({
+ name: categoryName,
+ status: categoryData.status,
+ reason: categoryData.reason,
+ details: categoryData,
+ })
+ } else {
+ Object.entries(categoryData).forEach(([subKey, subData]: [string, any]) => {
+ if (subData && typeof subData === "object" && "status" in subData) {
+ grouped[categoryName].push({
+ name: subKey,
+ status: subData.status,
+ reason: subData.reason,
+ details: subData,
+ })
+ }
+ })
+ }
+ })
+
+ return grouped
+ }
+
+ const getStatusIcon = (status: string) => {
+ const statusUpper = status?.toUpperCase()
+ switch (statusUpper) {
+ case "OK":
+ return
+ case "WARNING":
+ return
+ case "CRITICAL":
+ return
+ default:
+ return
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ const statusUpper = status?.toUpperCase()
+ switch (statusUpper) {
+ case "OK":
+ return Healthy
+ case "WARNING":
+ return Warning
+ case "CRITICAL":
+ return Critical
+ default:
+ return Unknown
+ }
+ }
+
+ const stats = getHealthStats()
+ const groupedChecks = getGroupedChecks()
+
+ return (
+
+
+
+
+
+ System Health Status
+
+ Detailed health checks for all system components
+
+
+ {loading && (
+
+
+
+ )}
+
+ {error && (
+
+
Error loading health status
+
{error}
+
+ )}
+
+ {healthData && !loading && (
+
+ {/* Overall Status Summary */}
+
+
+
+ Overall Status
+ {getStatusBadge(healthData.overall)}
+
+
+
+ {healthData.summary && {healthData.summary}
}
+
+
+
{stats.total}
+
Total Checks
+
+
+
{stats.healthy}
+
Healthy
+
+
+
{stats.warnings}
+
Warnings
+
+
+
{stats.critical}
+
Critical
+
+
+
+
+
+ {/* Grouped Health Checks */}
+ {Object.entries(groupedChecks).map(([category, checks]) => (
+
+
+ {category}
+
+
+
+ {checks.map((check, index) => (
+
+
{getStatusIcon(check.status)}
+
+
+
{check.name}
+
+ {check.status}
+
+
+ {check.reason &&
{check.reason}
}
+ {check.details && (
+
+ {Object.entries(check.details).map(([key, value]) => {
+ if (key === "status" || key === "reason" || typeof value === "object") return null
+ return (
+
+ {key}: {String(value)}
+
+ )
+ })}
+
+ )}
+
+
+ ))}
+
+
+
+ ))}
+
+ {healthData.timestamp && (
+
+ Last updated: {new Date(healthData.timestamp).toLocaleString()}
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx
new file mode 100644
index 0000000..38f3aac
--- /dev/null
+++ b/AppImage/components/login.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import type React from "react"
+
+import { useState } from "react"
+import { Button } from "./ui/button"
+import { Input } from "./ui/input"
+import { Label } from "./ui/label"
+import { Lock, User, AlertCircle, Server } from "lucide-react"
+import { getApiUrl } from "../lib/api-config"
+import Image from "next/image"
+
+interface LoginProps {
+ onLogin: () => void
+}
+
+export function Login({ onLogin }: LoginProps) {
+ const [username, setUsername] = useState("")
+ const [password, setPassword] = useState("")
+ const [error, setError] = useState("")
+ const [loading, setLoading] = useState(false)
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError("")
+
+ if (!username || !password) {
+ setError("Please enter username and password")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ const response = await fetch(getApiUrl("/api/auth/login"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error || "Login failed")
+ }
+
+ // Save token
+ localStorage.setItem("proxmenux-auth-token", data.token)
+ onLogin()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Login failed")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {
+ const target = e.target as HTMLImageElement
+ target.style.display = "none"
+ const fallback = target.parentElement?.querySelector(".fallback-icon")
+ if (fallback) {
+ fallback.classList.remove("hidden")
+ }
+ }}
+ />
+
+
+
+
+
ProxMenux Monitor
+
Sign in to access your dashboard
+
+
+
+
+
+
ProxMenux Monitor v1.0.1
+
+
+ )
+}
diff --git a/AppImage/components/onboarding-carousel.tsx b/AppImage/components/onboarding-carousel.tsx
index 32fddb6..87e4923 100644
--- a/AppImage/components/onboarding-carousel.tsx
+++ b/AppImage/components/onboarding-carousel.tsx
@@ -17,8 +17,13 @@ import {
Cpu,
FileText,
Rocket,
+ Zap,
+ Shield,
+ Link2,
+ Gauge,
} from "lucide-react"
import Image from "next/image"
+import { Checkbox } from "./ui/checkbox"
interface OnboardingSlide {
id: number
@@ -27,6 +32,7 @@ interface OnboardingSlide {
image?: string
icon: React.ReactNode
gradient: string
+ features?: { icon: React.ReactNode; text: string }[]
}
const slides: OnboardingSlide[] = [
@@ -40,6 +46,35 @@ 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: ,
+ gradient: "from-amber-500 via-orange-500 to-red-500",
+ features: [
+ {
+ icon: ,
+ text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
+ },
+ {
+ icon: ,
+ text: "Authentication System - Secure your dashboard with password protection",
+ },
+ {
+ icon: ,
+ text: "PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
+ },
+ {
+ icon: ,
+ text: "Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
+ },
+ {
+ icon: ,
+ 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.",
@@ -48,7 +83,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-cyan-500",
},
{
- id: 2,
+ id: 3,
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.",
@@ -57,7 +92,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-cyan-500 to-teal-500",
},
{
- id: 3,
+ id: 4,
title: "Network Metrics",
description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
@@ -66,7 +101,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-teal-500 to-green-500",
},
{
- id: 4,
+ id: 5,
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.",
@@ -75,7 +110,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-green-500 to-emerald-500",
},
{
- id: 5,
+ id: 6,
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.",
@@ -84,7 +119,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-emerald-500 to-blue-500",
},
{
- id: 6,
+ id: 7,
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.",
@@ -93,7 +128,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-indigo-500",
},
{
- id: 7,
+ id: 8,
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.",
@@ -106,6 +141,7 @@ export function OnboardingCarousel() {
const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next")
+ const [dontShowAgain, setDontShowAgain] = useState(false)
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
@@ -119,6 +155,9 @@ export function OnboardingCarousel() {
setDirection("next")
setCurrentSlide(currentSlide + 1)
} else {
+ if (dontShowAgain) {
+ localStorage.setItem("proxmenux-onboarding-seen", "true")
+ }
setOpen(false)
}
}
@@ -131,11 +170,16 @@ export function OnboardingCarousel() {
}
const handleSkip = () => {
+ if (dontShowAgain) {
+ localStorage.setItem("proxmenux-onboarding-seen", "true")
+ }
setOpen(false)
}
- const handleDontShowAgain = () => {
- localStorage.setItem("proxmenux-onboarding-seen", "true")
+ const handleClose = () => {
+ if (dontShowAgain) {
+ localStorage.setItem("proxmenux-onboarding-seen", "true")
+ }
setOpen(false)
}
@@ -147,7 +191,7 @@ export function OnboardingCarousel() {
const slide = slides[currentSlide]
return (
-
+
{/* Close button */}
@@ -155,7 +199,7 @@ export function OnboardingCarousel() {
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={handleSkip}
+ onClick={handleClose}
>
@@ -197,14 +241,28 @@ export function OnboardingCarousel() {
-
+
-
{slide.title}
-
+
{slide.title}
+
{slide.description}
+ {slide.features && (
+
+ {slide.features.map((feature, index) => (
+
+
{feature.icon}
+
{feature.text}
+
+ ))}
+
+ )}
+
{/* Progress dots */}
{slides.map((_, index) => (
@@ -221,12 +279,12 @@ export function OnboardingCarousel() {
))}
-
+
Previous
@@ -235,10 +293,17 @@ export function OnboardingCarousel() {
{currentSlide < slides.length - 1 ? (
<>
-
+
Skip
-
+
Next
@@ -246,7 +311,7 @@ export function OnboardingCarousel() {
) : (
Get Started!
@@ -255,17 +320,19 @@ export function OnboardingCarousel() {
- {/* Don't show again */}
- {currentSlide === slides.length - 1 && (
-
-
- Don't show again
-
-
- )}
+
+ setDontShowAgain(checked as boolean)}
+ />
+
+ Don't show this again
+
+
diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx
index da744ba..71651d1 100644
--- a/AppImage/components/proxmox-dashboard.tsx
+++ b/AppImage/components/proxmox-dashboard.tsx
@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { OnboardingCarousel } from "./onboarding-carousel"
+import { HealthStatusModal } from "./health-status-modal"
import { getApiUrl } from "../lib/api-config"
import {
RefreshCw,
@@ -63,6 +64,7 @@ export function ProxmoxDashboard() {
const [activeTab, setActiveTab] = useState("overview")
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
+ const [showHealthModal, setShowHealthModal] = useState(false)
const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...")
@@ -244,7 +246,10 @@ export function ProxmoxDashboard() {
)}
-
+
+
)
}
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx
new file mode 100644
index 0000000..3c290ff
--- /dev/null
+++ b/AppImage/components/settings.tsx
@@ -0,0 +1,434 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Button } from "./ui/button"
+import { Input } from "./ui/input"
+import { Label } from "./ui/label"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
+import { Shield, Lock, User, AlertCircle, CheckCircle, Info } from "lucide-react"
+import { getApiUrl } from "../lib/api-config"
+
+export function Settings() {
+ const [authEnabled, setAuthEnabled] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState("")
+ const [success, setSuccess] = useState("")
+
+ // Setup form state
+ const [showSetupForm, setShowSetupForm] = useState(false)
+ const [username, setUsername] = useState("")
+ const [password, setPassword] = useState("")
+ const [confirmPassword, setConfirmPassword] = useState("")
+
+ // Change password form state
+ const [showChangePassword, setShowChangePassword] = useState(false)
+ const [currentPassword, setCurrentPassword] = useState("")
+ const [newPassword, setNewPassword] = useState("")
+ const [confirmNewPassword, setConfirmNewPassword] = useState("")
+
+ useEffect(() => {
+ checkAuthStatus()
+ }, [])
+
+ const checkAuthStatus = async () => {
+ try {
+ const response = await fetch(getApiUrl("/api/auth/status"))
+ const data = await response.json()
+ setAuthEnabled(data.auth_enabled || false)
+ } catch (err) {
+ console.error("Failed to check auth status:", err)
+ }
+ }
+
+ const handleEnableAuth = async () => {
+ setError("")
+ setSuccess("")
+
+ if (!username || !password) {
+ setError("Please fill in all fields")
+ return
+ }
+
+ if (password !== confirmPassword) {
+ setError("Passwords do not match")
+ return
+ }
+
+ if (password.length < 6) {
+ setError("Password must be at least 6 characters")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ const response = await fetch(getApiUrl("/api/auth/setup"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username,
+ password,
+ enable_auth: true,
+ }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to enable authentication")
+ }
+
+ // Save token
+ localStorage.setItem("proxmenux-auth-token", data.token)
+ localStorage.setItem("proxmenux-auth-setup-complete", "true")
+
+ setSuccess("Authentication enabled successfully!")
+ setAuthEnabled(true)
+ setShowSetupForm(false)
+ setUsername("")
+ setPassword("")
+ setConfirmPassword("")
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to enable authentication")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDisableAuth = async () => {
+ if (
+ !confirm(
+ "Are you sure you want to disable authentication? This will remove password protection from your dashboard.",
+ )
+ ) {
+ return
+ }
+
+ setLoading(true)
+ setError("")
+ setSuccess("")
+
+ try {
+ const response = await fetch(getApiUrl("/api/auth/setup"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enable_auth: false }),
+ })
+
+ if (!response.ok) throw new Error("Failed to disable authentication")
+
+ localStorage.removeItem("proxmenux-auth-token")
+ setSuccess("Authentication disabled successfully!")
+ setAuthEnabled(false)
+ } catch (err) {
+ setError("Failed to disable authentication. Please try again.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleChangePassword = async () => {
+ setError("")
+ setSuccess("")
+
+ if (!currentPassword || !newPassword) {
+ setError("Please fill in all fields")
+ return
+ }
+
+ if (newPassword !== confirmNewPassword) {
+ setError("New passwords do not match")
+ return
+ }
+
+ if (newPassword.length < 6) {
+ setError("Password must be at least 6 characters")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ const response = await fetch(getApiUrl("/api/auth/change-password"), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
+ },
+ body: JSON.stringify({
+ current_password: currentPassword,
+ new_password: newPassword,
+ }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ throw new Error(data.error || "Failed to change password")
+ }
+
+ // Update token if provided
+ if (data.token) {
+ localStorage.setItem("proxmenux-auth-token", data.token)
+ }
+
+ setSuccess("Password changed successfully!")
+ setShowChangePassword(false)
+ setCurrentPassword("")
+ setNewPassword("")
+ setConfirmNewPassword("")
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to change password")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleLogout = () => {
+ localStorage.removeItem("proxmenux-auth-token")
+ window.location.reload()
+ }
+
+ return (
+
+
+
Settings
+
Manage your dashboard security and preferences
+
+
+ {/* Authentication Settings */}
+
+
+
+
+ Authentication
+
+ Protect your dashboard with username and password authentication
+
+
+ {error && (
+
+ )}
+
+ {success && (
+
+ )}
+
+
+
+
+
+
+
+
Authentication Status
+
+ {authEnabled ? "Password protection is enabled" : "No password protection"}
+
+
+
+
+ {authEnabled ? "Enabled" : "Disabled"}
+
+
+
+ {!authEnabled && !showSetupForm && (
+
+
+
+
+ Enable authentication to protect your dashboard when accessing from non-private networks.
+
+
+
setShowSetupForm(true)} className="w-full bg-blue-500 hover:bg-blue-600">
+
+ Enable Authentication
+
+
+ )}
+
+ {!authEnabled && showSetupForm && (
+
+
Setup Authentication
+
+
+
Username
+
+
+ setUsername(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
Password
+
+
+ setPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
Confirm Password
+
+
+ setConfirmPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
+ {loading ? "Enabling..." : "Enable"}
+
+ setShowSetupForm(false)} variant="outline" className="flex-1" disabled={loading}>
+ Cancel
+
+
+
+ )}
+
+ {authEnabled && (
+
+
+ Logout
+
+
+ {!showChangePassword && (
+
setShowChangePassword(true)} variant="outline" className="w-full">
+
+ Change Password
+
+ )}
+
+ {showChangePassword && (
+
+
Change Password
+
+
+
Current Password
+
+
+ setCurrentPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
New Password
+
+
+ setNewPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
Confirm New Password
+
+
+ setConfirmNewPassword(e.target.value)}
+ className="pl-10"
+ disabled={loading}
+ />
+
+
+
+
+
+ {loading ? "Changing..." : "Change Password"}
+
+ setShowChangePassword(false)}
+ variant="outline"
+ className="flex-1"
+ disabled={loading}
+ >
+ Cancel
+
+
+
+ )}
+
+
+ Disable Authentication
+
+
+ )}
+
+
+
+ {/* About Section */}
+
+
+ About
+ ProxMenux Monitor information
+
+
+
+ Version
+ 1.0.1
+
+
+ Build
+ Debian Package
+
+
+
+
+ )
+}
diff --git a/AppImage/components/sidebar.tsx b/AppImage/components/sidebar.tsx
index eae7fc3..c4acc78 100644
--- a/AppImage/components/sidebar.tsx
+++ b/AppImage/components/sidebar.tsx
@@ -1,10 +1,13 @@
-import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText } from "path-to-icons"
+import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } from "lucide-react"
const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
{ name: "Storage", href: "/storage", icon: HardDrive },
{ name: "Network", href: "/network", icon: Network },
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
- { name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section
+ { name: "Hardware", href: "/hardware", icon: Cpu },
{ name: "System Logs", href: "/logs", icon: FileText },
+ { name: "Settings", href: "/settings", icon: SettingsIcon },
]
+
+// ... existing code ...
diff --git a/AppImage/components/storage-metrics.tsx b/AppImage/components/storage-metrics.tsx
index abc5c0a..cc8b976 100644
--- a/AppImage/components/storage-metrics.tsx
+++ b/AppImage/components/storage-metrics.tsx
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
+import { formatStorage } from "@/lib/utils"
interface StorageData {
total: number
@@ -116,10 +117,10 @@ export function StorageMetrics() {
- {storageData.total.toFixed(1)} GB
+ {formatStorage(storageData.total)}
- {storageData.used.toFixed(1)} GB used • {storageData.available.toFixed(1)} GB available
+ {formatStorage(storageData.used)} used • {formatStorage(storageData.available)} available
@@ -130,7 +131,7 @@ export function StorageMetrics() {
- {storageData.used.toFixed(1)} GB
+ {formatStorage(storageData.used)}
{usagePercent.toFixed(1)}% of total space
@@ -144,7 +145,7 @@ export function StorageMetrics() {
- {storageData.available.toFixed(1)} GB
+ {formatStorage(storageData.available)}
{((storageData.available / storageData.total) * 100).toFixed(1)}% Free
@@ -201,7 +202,7 @@ export function StorageMetrics() {
- {disk.used.toFixed(1)} GB / {disk.total.toFixed(1)} GB
+ {formatStorage(disk.used)} / {formatStorage(disk.total)}
diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx
index 7debfe6..70ba7eb 100644
--- a/AppImage/components/storage-overview.tsx
+++ b/AppImage/components/storage-overview.tsx
@@ -6,6 +6,7 @@ import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Ther
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { getApiUrl } from "../lib/api-config"
interface DiskInfo {
name: string
@@ -75,12 +76,11 @@ const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) {
// Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB`
- } else if (sizeInGB < 1024) {
- // Less than 1024 GB, show in GB
- return `${sizeInGB.toFixed(1)} GB`
+ } else if (sizeInGB > 999) {
+ return `${(sizeInGB / 1024).toFixed(2)} TB`
} else {
- // 1024 GB or more, show in TB
- return `${(sizeInGB / 1024).toFixed(1)} TB`
+ // Between 1 and 999 GB, show in GB
+ return `${sizeInGB.toFixed(2)} GB`
}
}
@@ -93,12 +93,9 @@ export function StorageOverview() {
const fetchStorageData = async () => {
try {
- const baseUrl =
- typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
-
const [storageResponse, proxmoxResponse] = await Promise.all([
- fetch(`${baseUrl}/api/storage`),
- fetch(`${baseUrl}/api/proxmox-storage`),
+ fetch(getApiUrl("/api/storage")),
+ fetch(getApiUrl("/api/proxmox-storage")),
])
const data = await storageResponse.json()
@@ -107,6 +104,24 @@ export function StorageOverview() {
console.log("[v0] Storage data received:", data)
console.log("[v0] Proxmox storage data received:", proxmoxData)
+ if (proxmoxData && proxmoxData.storage) {
+ const activeStorages = proxmoxData.storage.filter(
+ (s: any) => s && s.total > 0 && s.used >= 0 && s.status?.toLowerCase() === "active",
+ )
+ console.log("[v0] Active storage volumes:", activeStorages.length)
+ console.log(
+ "[v0] Total used across all volumes (GB):",
+ activeStorages.reduce((sum: number, s: any) => sum + s.used, 0),
+ )
+
+ // Check for potential cluster node duplication
+ const storageNames = activeStorages.map((s: any) => s.name)
+ const uniqueNames = new Set(storageNames)
+ if (storageNames.length !== uniqueNames.size) {
+ console.warn("[v0] WARNING: Duplicate storage names detected - possible cluster node issue")
+ }
+ }
+
setStorageData(data)
setProxmoxStorage(proxmoxData)
} catch (error) {
@@ -402,15 +417,22 @@ export function StorageOverview() {
const diskHealthBreakdown = getDiskHealthBreakdown()
const diskTypesBreakdown = getDiskTypesBreakdown()
+ // Only sum storage that belongs to the current node or filter appropriately
const totalProxmoxUsed =
proxmoxStorage && proxmoxStorage.storage
? proxmoxStorage.storage
.filter(
- (storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active",
+ (storage) =>
+ storage &&
+ storage.total > 0 &&
+ storage.used >= 0 && // Added check for valid used value
+ storage.status &&
+ storage.status.toLowerCase() === "active",
)
.reduce((sum, storage) => sum + storage.used, 0)
: 0
+ // Convert storageData.total from TB to GB before calculating percentage
const usagePercent =
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
@@ -520,7 +542,14 @@ export function StorageOverview() {
{proxmoxStorage.storage
- .filter((storage) => storage && storage.name && storage.total > 0)
+ .filter(
+ (storage) =>
+ storage &&
+ 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))
.map((storage) => (
@@ -568,7 +597,7 @@ export function StorageOverview() {
Total
-
{storage.total.toLocaleString()} GB
+
{formatStorage(storage.total)}
Used
@@ -581,12 +610,12 @@ export function StorageOverview() {
: "text-blue-400"
}`}
>
- {storage.used.toLocaleString()} GB
+ {formatStorage(storage.used)}
Available
-
{storage.available.toLocaleString()} GB
+
{formatStorage(storage.available)}
diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx
index 850e1ac..20204f4 100644
--- a/AppImage/components/system-overview.tsx
+++ b/AppImage/components/system-overview.tsx
@@ -388,12 +388,11 @@ export function SystemOverview() {
if (sizeInGB < 1) {
// Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB`
- } else if (sizeInGB < 1024) {
- // Less than 1024 GB, show in GB
- return `${sizeInGB.toFixed(1)} GB`
- } else {
- // 1024 GB or more, show in TB
+ } else if (sizeInGB > 999) {
return `${(sizeInGB / 1024).toFixed(2)} TB`
+ } else {
+ // Between 1 and 999 GB, show in GB
+ return `${sizeInGB.toFixed(2)} GB`
}
}
diff --git a/AppImage/components/ui/checkbox.tsx b/AppImage/components/ui/checkbox.tsx
new file mode 100644
index 0000000..c697ed4
--- /dev/null
+++ b/AppImage/components/ui/checkbox.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/AppImage/components/ui/input.tsx b/AppImage/components/ui/input.tsx
index 31bbca4..d32a72e 100644
--- a/AppImage/components/ui/input.tsx
+++ b/AppImage/components/ui/input.tsx
@@ -9,7 +9,7 @@ const Input = React.forwardRef(({ className, type,
,
+ React.ComponentPropsWithoutRef & VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx
index d6ebc19..fd5cc4b 100644
--- a/AppImage/components/virtual-machines.tsx
+++ b/AppImage/components/virtual-machines.tsx
@@ -25,6 +25,7 @@ import {
} from "lucide-react"
import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
+import { formatStorage } from "@/lib/utils" // Import formatStorage utility
interface VMData {
vmid: number
@@ -194,18 +195,18 @@ const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_in
return "DHCP"
}
-const formatStorage = (sizeInGB: number): string => {
- if (sizeInGB < 1) {
- // Less than 1 GB, show in MB
- return `${(sizeInGB * 1024).toFixed(1)} MB`
- } else if (sizeInGB < 1024) {
- // Less than 1024 GB, show in GB
- return `${sizeInGB.toFixed(1)} GB`
- } else {
- // 1024 GB or more, show in TB
- return `${(sizeInGB / 1024).toFixed(1)} TB`
- }
-}
+// const formatStorage = (sizeInGB: number): string => {
+// if (sizeInGB < 1) {
+// // Less than 1 GB, show in MB
+// return `${(sizeInGB * 1024).toFixed(1)} MB`
+// } else if (sizeInGB < 1024) {
+// // Less than 1024 GB, show in GB
+// return `${sizeInGB.toFixed(1)} GB`
+// } else {
+// // 1024 GB or more, show in TB
+// return `${(sizeInGB / 1024).toFixed(1)} TB`
+// }
+// }
const getUsageColor = (percent: number): string => {
if (percent >= 95) return "text-red-500"
diff --git a/AppImage/lib/api-config.ts b/AppImage/lib/api-config.ts
index de2bcc0..f5aea73 100644
--- a/AppImage/lib/api-config.ts
+++ b/AppImage/lib/api-config.ts
@@ -11,21 +11,29 @@
*/
export function getApiBaseUrl(): string {
if (typeof window === "undefined") {
+ console.log("[v0] getApiBaseUrl: Running on server (SSR)")
return ""
}
const { protocol, hostname, port } = window.location
+ console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
+
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
// In this case, use relative URLs so the proxy handles routing
const isStandardPort = port === "" || port === "80" || port === "443"
+ console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
+
if (isStandardPort) {
// Behind a proxy - use relative URL
+ console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
return ""
} else {
// Direct access - use explicit port 8008
- return `${protocol}//${hostname}:8008`
+ const baseUrl = `${protocol}//${hostname}:8008`
+ console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
+ return baseUrl
}
}
diff --git a/AppImage/lib/utils.ts b/AppImage/lib/utils.ts
index d084cca..4024e48 100644
--- a/AppImage/lib/utils.ts
+++ b/AppImage/lib/utils.ts
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+export function formatStorage(sizeInGB: number): string {
+ if (sizeInGB < 1) {
+ // Less than 1 GB, show in MB
+ const mb = sizeInGB * 1024
+ return `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`
+ } else if (sizeInGB < 1024) {
+ // Less than 1024 GB, show in GB
+ return `${sizeInGB % 1 === 0 ? sizeInGB.toFixed(0) : sizeInGB.toFixed(1)} GB`
+ } else {
+ // 1024 GB or more, show in TB
+ const tb = sizeInGB / 1024
+ return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
+ }
+}
diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py
new file mode 100644
index 0000000..1fbda2b
--- /dev/null
+++ b/AppImage/scripts/auth_manager.py
@@ -0,0 +1,279 @@
+"""
+Authentication Manager Module
+Handles all authentication-related operations including:
+- Loading/saving auth configuration
+- Password hashing and verification
+- JWT token generation and validation
+- Auth status checking
+"""
+
+import os
+import json
+import hashlib
+from datetime import datetime, timedelta
+from pathlib import Path
+
+try:
+ import jwt
+ JWT_AVAILABLE = True
+except ImportError:
+ JWT_AVAILABLE = False
+ print("Warning: PyJWT not available. Authentication features will be limited.")
+
+# Configuration
+CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
+AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
+JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production"
+JWT_ALGORITHM = "HS256"
+TOKEN_EXPIRATION_HOURS = 24
+
+
+def ensure_config_dir():
+ """Ensure the configuration directory exists"""
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def load_auth_config():
+ """
+ Load authentication configuration from file
+ Returns dict with structure:
+ {
+ "enabled": bool,
+ "username": str,
+ "password_hash": str,
+ "declined": bool, # True if user explicitly declined auth
+ "configured": bool # True if auth has been set up (enabled or declined)
+ }
+ """
+ if not AUTH_CONFIG_FILE.exists():
+ return {
+ "enabled": False,
+ "username": None,
+ "password_hash": None,
+ "declined": False,
+ "configured": False
+ }
+
+ try:
+ with open(AUTH_CONFIG_FILE, 'r') as f:
+ config = json.load(f)
+ # Ensure all required fields exist
+ config.setdefault("declined", False)
+ config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
+ return config
+ except Exception as e:
+ print(f"Error loading auth config: {e}")
+ return {
+ "enabled": False,
+ "username": None,
+ "password_hash": None,
+ "declined": False,
+ "configured": False
+ }
+
+
+def save_auth_config(config):
+ """Save authentication configuration to file"""
+ ensure_config_dir()
+ try:
+ with open(AUTH_CONFIG_FILE, 'w') as f:
+ json.dump(config, f, indent=2)
+ return True
+ except Exception as e:
+ print(f"Error saving auth config: {e}")
+ return False
+
+
+def hash_password(password):
+ """Hash a password using SHA-256"""
+ return hashlib.sha256(password.encode()).hexdigest()
+
+
+def verify_password(password, password_hash):
+ """Verify a password against its hash"""
+ return hash_password(password) == password_hash
+
+
+def generate_token(username):
+ """Generate a JWT token for the given username"""
+ if not JWT_AVAILABLE:
+ return None
+
+ payload = {
+ 'username': username,
+ 'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS),
+ 'iat': datetime.utcnow()
+ }
+
+ try:
+ token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
+ return token
+ except Exception as e:
+ print(f"Error generating token: {e}")
+ return None
+
+
+def verify_token(token):
+ """
+ Verify a JWT token
+ Returns username if valid, None otherwise
+ """
+ if not JWT_AVAILABLE or not token:
+ return None
+
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+ return payload.get('username')
+ except jwt.ExpiredSignatureError:
+ print("Token has expired")
+ return None
+ except jwt.InvalidTokenError as e:
+ print(f"Invalid token: {e}")
+ return None
+
+
+def get_auth_status():
+ """
+ Get current authentication status
+ Returns dict with:
+ {
+ "auth_enabled": bool,
+ "auth_configured": bool,
+ "declined": bool,
+ "username": str or None,
+ "authenticated": bool
+ }
+ """
+ config = load_auth_config()
+ return {
+ "auth_enabled": config.get("enabled", False),
+ "auth_configured": config.get("configured", False), # Frontend expects this field name
+ "declined": config.get("declined", False),
+ "username": config.get("username") if config.get("enabled") else None,
+ "authenticated": False # Will be set to True by the route handler if token is valid
+ }
+
+
+def setup_auth(username, password):
+ """
+ Set up authentication with username and password
+ Returns (success: bool, message: str)
+ """
+ if not username or not password:
+ return False, "Username and password are required"
+
+ if len(password) < 6:
+ return False, "Password must be at least 6 characters"
+
+ config = {
+ "enabled": True,
+ "username": username,
+ "password_hash": hash_password(password),
+ "declined": False,
+ "configured": True
+ }
+
+ if save_auth_config(config):
+ return True, "Authentication configured successfully"
+ else:
+ return False, "Failed to save authentication configuration"
+
+
+def decline_auth():
+ """
+ Mark authentication as declined by user
+ Returns (success: bool, message: str)
+ """
+ config = load_auth_config()
+ config["enabled"] = False
+ config["declined"] = True
+ config["configured"] = True
+ config["username"] = None
+ config["password_hash"] = None
+
+ if save_auth_config(config):
+ return True, "Authentication declined"
+ else:
+ return False, "Failed to save configuration"
+
+
+def disable_auth():
+ """
+ Disable authentication (different from decline - can be re-enabled)
+ Returns (success: bool, message: str)
+ """
+ config = load_auth_config()
+ config["enabled"] = False
+ # Keep configured=True and don't set declined=True
+ # This allows re-enabling without showing the setup modal again
+
+ if save_auth_config(config):
+ return True, "Authentication disabled"
+ else:
+ return False, "Failed to save configuration"
+
+
+def enable_auth():
+ """
+ Enable authentication (must already be configured)
+ Returns (success: bool, message: str)
+ """
+ config = load_auth_config()
+
+ if not config.get("username") or not config.get("password_hash"):
+ return False, "Authentication not configured. Please set up username and password first."
+
+ config["enabled"] = True
+ config["declined"] = False
+
+ if save_auth_config(config):
+ return True, "Authentication enabled"
+ else:
+ return False, "Failed to save configuration"
+
+
+def change_password(old_password, new_password):
+ """
+ Change the authentication password
+ Returns (success: bool, message: str)
+ """
+ config = load_auth_config()
+
+ if not config.get("enabled"):
+ return False, "Authentication is not enabled"
+
+ if not verify_password(old_password, config.get("password_hash", "")):
+ return False, "Current password is incorrect"
+
+ if len(new_password) < 6:
+ return False, "New password must be at least 6 characters"
+
+ config["password_hash"] = hash_password(new_password)
+
+ if save_auth_config(config):
+ return True, "Password changed successfully"
+ else:
+ return False, "Failed to save new password"
+
+
+def authenticate(username, password):
+ """
+ Authenticate a user with username and password
+ Returns (success: bool, token: str or None, message: str)
+ """
+ config = load_auth_config()
+
+ if not config.get("enabled"):
+ return False, None, "Authentication is not enabled"
+
+ if username != config.get("username"):
+ return False, None, "Invalid username or password"
+
+ if not verify_password(password, config.get("password_hash", "")):
+ return False, None, "Invalid username or password"
+
+ token = generate_token(username)
+ if token:
+ return True, token, "Authentication successful"
+ else:
+ return False, None, "Failed to generate authentication token"
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index 66493f3..f43b901 100644
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -78,6 +78,10 @@ cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
+cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
+cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
+cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
+cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
echo "📋 Adding translation support..."
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
@@ -279,6 +283,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
flask-cors \
psutil \
requests \
+ PyJWT \
googletrans==4.0.0-rc1 \
httpx==0.13.3 \
httpcore==0.9.1 \
@@ -321,10 +326,6 @@ echo "🔧 Installing hardware monitoring tools..."
mkdir -p "$WORK_DIR/debs"
cd "$WORK_DIR/debs"
-
-# ==============================================================
-
-
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
dl_pkg() {
@@ -361,21 +362,12 @@ dl_pkg() {
return 1
}
-mkdir -p "$WORK_DIR/debs"
-cd "$WORK_DIR/debs"
-
-
dl_pkg "ipmitool.deb" "ipmitool" || true
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
dl_pkg "lm-sensors.deb" "lm-sensors" || true
dl_pkg "nut-client.deb" "nut-client" || true
dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true
-
-# dl_pkg "nvidia-smi.deb" "nvidia-smi" "nvidia-utils" "nvidia-utils-535" "nvidia-utils-550" || true
-# dl_pkg "intel-gpu-tools.deb" "intel-gpu-tools" || true
-# dl_pkg "radeontop.deb" "radeontop" || true
-
echo "📦 Extracting .deb packages into AppDir..."
extracted_count=0
shopt -s nullglob
@@ -395,7 +387,6 @@ else
echo "✅ Extracted $extracted_count package(s)"
fi
-
if [ -d "$APP_DIR/bin" ]; then
echo "📋 Normalizing /bin -> /usr/bin"
mkdir -p "$APP_DIR/usr/bin"
@@ -403,24 +394,20 @@ if [ -d "$APP_DIR/bin" ]; then
rm -rf "$APP_DIR/bin"
fi
-
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
-
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
exit 1
fi
-
if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then
echo "❌ ipmitool has unresolved libs:"
ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true
exit 1
fi
-
if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
echo "⚠️ upsc has unresolved libs, trying to auto-fix..."
missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')"
@@ -463,12 +450,6 @@ echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
[ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing"
[ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing"
-
-
-# ==============================================================
-
-
-
# Build AppImage
echo "🔨 Building unified AppImage v${VERSION}..."
cd "$WORK_DIR"
diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py
new file mode 100644
index 0000000..1b04059
--- /dev/null
+++ b/AppImage/scripts/flask_auth_routes.py
@@ -0,0 +1,121 @@
+"""
+Flask Authentication Routes
+Provides REST API endpoints for authentication management
+"""
+
+from flask import Blueprint, jsonify, request
+import auth_manager
+
+auth_bp = Blueprint('auth', __name__)
+
+@auth_bp.route('/api/auth/status', methods=['GET'])
+def auth_status():
+ """Get current authentication status"""
+ try:
+ status = auth_manager.get_auth_status()
+
+ token = request.headers.get('Authorization', '').replace('Bearer ', '')
+ if token:
+ username = auth_manager.verify_token(token)
+ if username:
+ status['authenticated'] = True
+
+ return jsonify(status)
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/setup', methods=['POST'])
+def auth_setup():
+ """Set up authentication with username and password"""
+ try:
+ data = request.json
+ username = data.get('username')
+ password = data.get('password')
+
+ success, message = auth_manager.setup_auth(username, password)
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/decline', methods=['POST'])
+def auth_decline():
+ """Decline authentication setup"""
+ try:
+ success, message = auth_manager.decline_auth()
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/login', methods=['POST'])
+def auth_login():
+ """Authenticate user and return JWT token"""
+ try:
+ data = request.json
+ username = data.get('username')
+ password = data.get('password')
+
+ success, token, message = auth_manager.authenticate(username, password)
+
+ if success:
+ return jsonify({"success": True, "token": token, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 401
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/enable', methods=['POST'])
+def auth_enable():
+ """Enable authentication"""
+ try:
+ success, message = auth_manager.enable_auth()
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/disable', methods=['POST'])
+def auth_disable():
+ """Disable authentication"""
+ try:
+ success, message = auth_manager.disable_auth()
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
+
+
+@auth_bp.route('/api/auth/change-password', methods=['POST'])
+def auth_change_password():
+ """Change authentication password"""
+ try:
+ data = request.json
+ old_password = data.get('old_password')
+ new_password = data.get('new_password')
+
+ success, message = auth_manager.change_password(old_password, new_password)
+
+ if success:
+ return jsonify({"success": True, "message": message})
+ else:
+ return jsonify({"success": False, "message": message}), 400
+ except Exception as e:
+ return jsonify({"success": False, "message": str(e)}), 500
diff --git a/AppImage/scripts/flask_health_routes.py b/AppImage/scripts/flask_health_routes.py
new file mode 100644
index 0000000..766c3e4
--- /dev/null
+++ b/AppImage/scripts/flask_health_routes.py
@@ -0,0 +1,26 @@
+"""
+Flask routes for health monitoring
+"""
+
+from flask import Blueprint, jsonify
+from health_monitor import health_monitor
+
+health_bp = Blueprint('health', __name__)
+
+@health_bp.route('/api/health/status', methods=['GET'])
+def get_health_status():
+ """Get overall health status summary"""
+ try:
+ status = health_monitor.get_overall_status()
+ return jsonify(status)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+@health_bp.route('/api/health/details', methods=['GET'])
+def get_health_details():
+ """Get detailed health status with all checks"""
+ try:
+ details = health_monitor.get_detailed_status()
+ return jsonify(details)
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 783c611..654ed92 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -12,6 +12,7 @@ import psutil
import subprocess
import json
import os
+import sys
import time
import socket
from datetime import datetime, timedelta
@@ -22,10 +23,26 @@ import xml.etree.ElementTree as ET # Added for XML parsing
import math # Imported math for format_bytes function
import urllib.parse # Added for URL encoding
import platform # Added for platform.release()
+import hashlib
+import secrets
+import jwt
+from functools import wraps
+from pathlib import Path
+
+from flask_health_routes import health_bp
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from flask_auth_routes import auth_bp
app = Flask(__name__)
CORS(app) # Enable CORS for Next.js frontend
+app.register_blueprint(auth_bp)
+app.register_blueprint(health_bp)
+
+
+
def identify_gpu_type(name, vendor=None, bus=None, driver=None):
"""
Returns: 'Integrated' or 'PCI' (discrete)
@@ -395,18 +412,18 @@ def get_intel_gpu_processes_from_text():
processes.append(process_info)
except (ValueError, IndexError) as e:
- # print(f"[v0] Error parsing process line: {e}", flush=True)
+ # print(f"[v0] Error parsing process line: {e}")
pass
continue
break
if not header_found:
- # print(f"[v0] No process table found in intel_gpu_top output", flush=True)
+ # print(f"[v0] No process table found in intel_gpu_top output")
pass
return processes
except Exception as e:
- # print(f"[v0] Error getting processes from intel_gpu_top text: {e}", flush=True)
+ # print(f"[v0] Error getting processes from intel_gpu_top text: {e}")
pass
import traceback
traceback.print_exc()
@@ -917,6 +934,183 @@ def get_disk_hardware_info(disk_name):
"""Placeholder for disk hardware info - to be populated by lsblk later."""
return {}
+def get_pcie_link_speed(disk_name):
+ """Get PCIe link speed information for NVMe drives"""
+ pcie_info = {
+ 'pcie_gen': None,
+ 'pcie_width': None,
+ 'pcie_max_gen': None,
+ 'pcie_max_width': None
+ }
+
+ try:
+ # For NVMe drives, get PCIe information from sysfs
+ if disk_name.startswith('nvme'):
+ # Extract controller name properly using regex
+ import re
+ match = re.match(r'(nvme\d+)n\d+', disk_name)
+ if not match:
+ print(f"[v0] Could not extract controller from {disk_name}")
+ return pcie_info
+
+ controller = match.group(1) # nvme0n1 -> nvme0
+ print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
+
+ # Path to PCIe device in sysfs
+ sys_path = f'/sys/class/nvme/{controller}/device'
+
+ print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
+
+ if os.path.exists(sys_path):
+ try:
+ pci_address = os.path.basename(os.readlink(sys_path))
+ print(f"[v0] PCI address for {disk_name}: {pci_address}")
+
+ # Use lspci to get detailed PCIe information
+ result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
+ capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ print(f"[v0] lspci output for {pci_address}:")
+ for line in result.stdout.split('\n'):
+ # Look for "LnkSta:" line which shows current link status
+ if 'LnkSta:' in line:
+ print(f"[v0] Found LnkSta: {line}")
+ # Example: "LnkSta: Speed 8GT/s, Width x4"
+ if 'Speed' in line:
+ speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
+ if speed_match:
+ gt_s = float(speed_match.group(1))
+ if gt_s <= 2.5:
+ pcie_info['pcie_gen'] = '1.0'
+ elif gt_s <= 5.0:
+ pcie_info['pcie_gen'] = '2.0'
+ elif gt_s <= 8.0:
+ pcie_info['pcie_gen'] = '3.0'
+ elif gt_s <= 16.0:
+ pcie_info['pcie_gen'] = '4.0'
+ else:
+ pcie_info['pcie_gen'] = '5.0'
+ print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
+
+ if 'Width' in line:
+ width_match = re.search(r'Width\s+x(\d+)', line)
+ if width_match:
+ pcie_info['pcie_width'] = f'x{width_match.group(1)}'
+ print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
+
+ # Look for "LnkCap:" line which shows maximum capabilities
+ elif 'LnkCap:' in line:
+ print(f"[v0] Found LnkCap: {line}")
+ if 'Speed' in line:
+ speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
+ if speed_match:
+ gt_s = float(speed_match.group(1))
+ if gt_s <= 2.5:
+ pcie_info['pcie_max_gen'] = '1.0'
+ elif gt_s <= 5.0:
+ pcie_info['pcie_max_gen'] = '2.0'
+ elif gt_s <= 8.0:
+ pcie_info['pcie_max_gen'] = '3.0'
+ elif gt_s <= 16.0:
+ pcie_info['pcie_max_gen'] = '4.0'
+ else:
+ pcie_info['pcie_max_gen'] = '5.0'
+ print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
+
+ if 'Width' in line:
+ width_match = re.search(r'Width\s+x(\d+)', line)
+ if width_match:
+ pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
+ print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
+ else:
+ print(f"[v0] lspci failed with return code: {result.returncode}")
+ except Exception as e:
+ print(f"[v0] Error getting PCIe info via lspci: {e}")
+ import traceback
+ traceback.print_exc()
+ else:
+ print(f"[v0] sys_path does not exist: {sys_path}")
+ alt_sys_path = f'/sys/block/{disk_name}/device/device'
+ print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
+
+ if os.path.exists(alt_sys_path):
+ try:
+ # Get PCI address from the alternative path
+ pci_address = os.path.basename(os.readlink(alt_sys_path))
+ print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
+
+ # Use lspci to get detailed PCIe information
+ result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
+ capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ print(f"[v0] lspci output for {pci_address} (from alt path):")
+ for line in result.stdout.split('\n'):
+ # Look for "LnkSta:" line which shows current link status
+ if 'LnkSta:' in line:
+ print(f"[v0] Found LnkSta: {line}")
+ if 'Speed' in line:
+ speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
+ if speed_match:
+ gt_s = float(speed_match.group(1))
+ if gt_s <= 2.5:
+ pcie_info['pcie_gen'] = '1.0'
+ elif gt_s <= 5.0:
+ pcie_info['pcie_gen'] = '2.0'
+ elif gt_s <= 8.0:
+ pcie_info['pcie_gen'] = '3.0'
+ elif gt_s <= 16.0:
+ pcie_info['pcie_gen'] = '4.0'
+ else:
+ pcie_info['pcie_gen'] = '5.0'
+ print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
+
+ if 'Width' in line:
+ width_match = re.search(r'Width\s+x(\d+)', line)
+ if width_match:
+ pcie_info['pcie_width'] = f'x{width_match.group(1)}'
+ print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
+
+ # Look for "LnkCap:" line which shows maximum capabilities
+ elif 'LnkCap:' in line:
+ print(f"[v0] Found LnkCap: {line}")
+ if 'Speed' in line:
+ speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
+ if speed_match:
+ gt_s = float(speed_match.group(1))
+ if gt_s <= 2.5:
+ pcie_info['pcie_max_gen'] = '1.0'
+ elif gt_s <= 5.0:
+ pcie_info['pcie_max_gen'] = '2.0'
+ elif gt_s <= 8.0:
+ pcie_info['pcie_max_gen'] = '3.0'
+ elif gt_s <= 16.0:
+ pcie_info['pcie_max_gen'] = '4.0'
+ else:
+ pcie_info['pcie_max_gen'] = '5.0'
+ print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
+
+ if 'Width' in line:
+ width_match = re.search(r'Width\s+x(\d+)', line)
+ if width_match:
+ pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
+ print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
+ else:
+ print(f"[v0] lspci failed with return code: {result.returncode}")
+ except Exception as e:
+ print(f"[v0] Error getting PCIe info from alt path: {e}")
+ import traceback
+ traceback.print_exc()
+
+ except Exception as e:
+ print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+ print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
+ return pcie_info
+
+# get_pcie_link_speed function definition ends here
+
def get_smart_data(disk_name):
"""Get SMART data for a specific disk - Enhanced with multiple device type attempts"""
smart_data = {
@@ -947,8 +1141,8 @@ def get_smart_data(disk_name):
try:
commands_to_try = [
['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON output (preferred)
- ['smartctl', '-a', '-j', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type
- ['smartctl', '-a', '-j', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
+ ['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type
+ ['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback)
['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # Text with ATA device type
['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # Text with SAT device type
@@ -2013,7 +2207,7 @@ def get_proxmox_vms():
# print(f"[v0] Error getting VM/LXC info: {e}")
pass
return {
- 'error': f'Unable to access VM information: {str(e)}',
+ 'error': 'Unable to access VM information: {str(e)}',
'vms': []
}
except Exception as e:
@@ -2548,7 +2742,7 @@ def get_detailed_gpu_info(gpu):
if 'clients' in json_data:
client_count = len(json_data['clients'])
- for client_id, client_data in json_data['clients'].items():
+ for client_id, client_data in json_data['clients']:
client_name = client_data.get('name', 'Unknown')
client_pid = client_data.get('pid', 'Unknown')
@@ -2630,7 +2824,7 @@ def get_detailed_gpu_info(gpu):
clients = best_json['clients']
processes = []
- for client_id, client_data in clients.items():
+ for client_id, client_data in clients:
process_info = {
'name': client_data.get('name', 'Unknown'),
'pid': client_data.get('pid', 'Unknown'),
@@ -3091,22 +3285,22 @@ def get_detailed_gpu_info(gpu):
# print(f"[v0] Temperature: {detailed_info['temperature']}°C", flush=True)
pass
data_retrieved = True
-
- # Parse power draw (GFX Power or average_socket_power)
- if 'GFX Power' in sensors:
- gfx_power = sensors['GFX Power']
- if 'value' in gfx_power:
- detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W"
- # print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
- pass
- data_retrieved = True
- elif 'average_socket_power' in sensors:
- socket_power = sensors['average_socket_power']
- if 'value' in socket_power:
- detailed_info['power_draw'] = f"{socket_power['value']:.2f} W"
- # print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
- pass
- data_retrieved = True
+
+ # Parse power draw (GFX Power or average_socket_power)
+ if 'GFX Power' in sensors:
+ gfx_power = sensors['GFX Power']
+ if 'value' in gfx_power:
+ detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W"
+ # print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
+ pass
+ data_retrieved = True
+ elif 'average_socket_power' in sensors:
+ socket_power = sensors['average_socket_power']
+ if 'value' in socket_power:
+ detailed_info['power_draw'] = f"{socket_power['value']:.2f} W"
+ # print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
+ pass
+ data_retrieved = True
# Parse clocks (GFX_SCLK for graphics, GFX_MCLK for memory)
if 'Clocks' in device:
@@ -3115,7 +3309,7 @@ def get_detailed_gpu_info(gpu):
gfx_clock = clocks['GFX_SCLK']
if 'value' in gfx_clock:
detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz"
- # print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']}", flush=True)
+ # print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']} MHz", flush=True)
pass
data_retrieved = True
@@ -3123,7 +3317,7 @@ def get_detailed_gpu_info(gpu):
mem_clock = clocks['GFX_MCLK']
if 'value' in mem_clock:
detailed_info['clock_memory'] = f"{mem_clock['value']} MHz"
- # print(f"[v0] Memory Clock: {detailed_info['clock_memory']}", flush=True)
+ # print(f"[v0] Memory Clock: {detailed_info['clock_memory']} MHz", flush=True)
pass
data_retrieved = True
@@ -3360,7 +3554,6 @@ def get_detailed_gpu_info(gpu):
else:
# print(f"[v0] No fdinfo section found in device data", flush=True)
pass
- detailed_info['processes'] = []
if data_retrieved:
detailed_info['has_monitoring_tool'] = True
@@ -3886,6 +4079,10 @@ def get_hardware_info():
except:
pass
+ pcie_info = {}
+ if disk_name.startswith('nvme'):
+ pcie_info = get_pcie_link_speed(disk_name)
+
# Build storage device with all available information
storage_device = {
'name': disk_name,
@@ -3901,6 +4098,9 @@ def get_hardware_info():
'sata_version': sata_version,
}
+ if pcie_info:
+ storage_device.update(pcie_info)
+
# Add family if available (from smartctl)
try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
@@ -3922,7 +4122,7 @@ def get_hardware_info():
# print(f"[v0] Error getting storage info: {e}")
pass
- # Graphics Cards (from lspci - will be duplicated by new PCI device listing, but kept for now)
+ # Graphics Cards
try:
# Try nvidia-smi first
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'],
@@ -4467,7 +4667,7 @@ def api_network_interface_metrics(interface_name):
for point in all_data:
filtered_point = {'time': point.get('time')}
# Add network fields if they exist
- for key in ['netin', 'netout', 'diskread', 'diskwrite']:
+ for key in ['netin', 'netout']:
if key in point:
filtered_point[key] = point[key]
rrd_data.append(filtered_point)
@@ -5379,10 +5579,6 @@ def api_prometheus():
mem_used_bytes = mem_used * 1024 * 1024 # Convert MiB to bytes
mem_total_bytes = mem_total * 1024 * 1024
- metrics.append(f'# HELP proxmox_gpu_memory_used_bytes GPU memory used in bytes')
- metrics.append(f'# TYPE proxmox_gpu_memory_used_bytes gauge')
- metrics.append(f'proxmox_gpu_memory_used_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_used_bytes} {timestamp}')
-
metrics.append(f'# HELP proxmox_gpu_memory_total_bytes GPU memory total in bytes')
metrics.append(f'# TYPE proxmox_gpu_memory_total_bytes gauge')
metrics.append(f'proxmox_gpu_memory_total_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_total_bytes} {timestamp}')
@@ -5475,7 +5671,7 @@ def api_system_info():
except:
pass
- # Try to get node info from Proxmox API
+ # Try to get node info from Proxmox API
try:
result = subprocess.run(['pvesh', 'get', '/nodes', '--output-format', 'json'],
capture_output=True, text=True, timeout=5)
diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py
new file mode 100644
index 0000000..7eea8f1
--- /dev/null
+++ b/AppImage/scripts/health_monitor.py
@@ -0,0 +1,1176 @@
+"""
+ProxMenux Health Monitor Module
+Provides comprehensive, lightweight health checks for Proxmox systems.
+Optimized for minimal system impact with intelligent thresholds and hysteresis.
+
+Author: MacRimi
+Version: 1.0 (Light Health Logic)
+"""
+
+import psutil
+import subprocess
+import json
+import time
+import os
+from typing import Dict, List, Any, Tuple
+from datetime import datetime, timedelta
+from collections import defaultdict
+
+class HealthMonitor:
+ """
+ Monitors system health across multiple components with minimal impact.
+ Implements hysteresis, intelligent caching, and progressive escalation.
+ """
+
+ # CPU Thresholds
+ CPU_WARNING = 85
+ CPU_CRITICAL = 95
+ CPU_RECOVERY = 75
+ CPU_WARNING_DURATION = 60 # seconds
+ CPU_CRITICAL_DURATION = 120 # seconds
+ CPU_RECOVERY_DURATION = 120 # seconds
+
+ # Memory Thresholds
+ MEMORY_WARNING = 85
+ MEMORY_CRITICAL = 95
+ MEMORY_DURATION = 60 # seconds
+ SWAP_WARNING_DURATION = 300 # 5 minutes
+ SWAP_CRITICAL_PERCENT = 5 # 5% of RAM
+ SWAP_CRITICAL_DURATION = 120 # 2 minutes
+
+ # Storage Thresholds
+ STORAGE_WARNING = 85
+ STORAGE_CRITICAL = 95
+
+ # Temperature Thresholds
+ TEMP_WARNING = 80
+ TEMP_CRITICAL = 90
+
+ # Network Thresholds
+ NETWORK_LATENCY_WARNING = 100 # ms
+ NETWORK_LATENCY_CRITICAL = 300 # ms
+ NETWORK_TIMEOUT = 0.9 # seconds
+ NETWORK_INACTIVE_DURATION = 600 # 10 minutes
+
+ # Log Thresholds
+ LOG_ERRORS_WARNING = 5
+ LOG_ERRORS_CRITICAL = 6
+ LOG_WARNINGS_WARNING = 10
+ LOG_WARNINGS_CRITICAL = 30
+ LOG_CHECK_INTERVAL = 300 # 5 minutes
+
+ # Critical keywords for immediate escalation
+ CRITICAL_LOG_KEYWORDS = [
+ 'I/O error', 'EXT4-fs error', 'XFS', 'LVM activation failed',
+ 'md/raid: device failed', 'Out of memory', 'kernel panic',
+ 'filesystem read-only', 'cannot mount'
+ ]
+
+ # PVE Critical Services
+ PVE_SERVICES = ['pveproxy', 'pvedaemon', 'pvestatd', 'pve-cluster']
+
+ def __init__(self):
+ """Initialize health monitor with state tracking"""
+ self.state_history = defaultdict(list) # For hysteresis
+ self.last_check_times = {} # Cache check times
+ self.cached_results = {} # Cache results
+ self.network_baseline = {} # Network traffic baseline
+ self.io_error_history = defaultdict(list) # I/O error tracking
+
+ def get_overall_status(self) -> Dict[str, Any]:
+ """Get overall health status summary with minimal overhead"""
+ details = self.get_detailed_status()
+
+ overall_status = details.get('overall', 'OK')
+ summary = details.get('summary', '')
+
+ # Count statuses
+ critical_count = 0
+ warning_count = 0
+ ok_count = 0
+
+ for category, data in details.get('details', {}).items():
+ if isinstance(data, dict):
+ status = data.get('status', 'OK')
+ if status == 'CRITICAL':
+ critical_count += 1
+ elif status == 'WARNING':
+ warning_count += 1
+ elif status == 'OK':
+ ok_count += 1
+
+ return {
+ 'status': overall_status,
+ 'summary': summary,
+ 'critical_count': critical_count,
+ 'warning_count': warning_count,
+ 'ok_count': ok_count,
+ 'timestamp': datetime.now().isoformat()
+ }
+
+ def get_detailed_status(self) -> Dict[str, Any]:
+ """
+ Get comprehensive health status with all checks.
+ Returns JSON structure matching the specification.
+ """
+ details = {}
+ critical_issues = []
+ warning_issues = []
+
+ # Priority 1: Services PVE / FS / Storage
+ services_status = self._check_pve_services()
+ details['services'] = services_status
+ if services_status['status'] == 'CRITICAL':
+ critical_issues.append(services_status.get('reason', 'Service failure'))
+ elif services_status['status'] == 'WARNING':
+ warning_issues.append(services_status.get('reason', 'Service issue'))
+
+ storage_status = self._check_storage_comprehensive()
+ details['storage'] = storage_status
+ for storage_name, storage_data in storage_status.items():
+ if isinstance(storage_data, dict):
+ if storage_data.get('status') == 'CRITICAL':
+ critical_issues.append(f"{storage_name}: {storage_data.get('reason', 'Storage failure')}")
+ elif storage_data.get('status') == 'WARNING':
+ warning_issues.append(f"{storage_name}: {storage_data.get('reason', 'Storage issue')}")
+
+ # Priority 2: Disks / I/O
+ disks_status = self._check_disks_io()
+ details['disks'] = disks_status
+ for disk_name, disk_data in disks_status.items():
+ if isinstance(disk_data, dict):
+ if disk_data.get('status') == 'CRITICAL':
+ critical_issues.append(f"{disk_name}: {disk_data.get('reason', 'Disk failure')}")
+ elif disk_data.get('status') == 'WARNING':
+ warning_issues.append(f"{disk_name}: {disk_data.get('reason', 'Disk issue')}")
+
+ # Priority 3: VM/CT
+ vms_status = self._check_vms_cts()
+ details['vms'] = vms_status
+ if vms_status.get('status') == 'CRITICAL':
+ critical_issues.append(vms_status.get('reason', 'VM/CT failure'))
+ elif vms_status.get('status') == 'WARNING':
+ warning_issues.append(vms_status.get('reason', 'VM/CT issue'))
+
+ # Priority 4: Network
+ network_status = self._check_network_comprehensive()
+ details['network'] = network_status
+ if network_status.get('status') == 'CRITICAL':
+ critical_issues.append(network_status.get('reason', 'Network failure'))
+ elif network_status.get('status') == 'WARNING':
+ warning_issues.append(network_status.get('reason', 'Network issue'))
+
+ # Priority 5: CPU/RAM
+ cpu_status = self._check_cpu_with_hysteresis()
+ details['cpu'] = cpu_status
+ if cpu_status.get('status') == 'WARNING':
+ warning_issues.append(cpu_status.get('reason', 'CPU high'))
+
+ memory_status = self._check_memory_comprehensive()
+ details['memory'] = memory_status
+ if memory_status.get('status') == 'CRITICAL':
+ critical_issues.append(memory_status.get('reason', 'Memory critical'))
+ elif memory_status.get('status') == 'WARNING':
+ warning_issues.append(memory_status.get('reason', 'Memory high'))
+
+ # Priority 6: Logs
+ logs_status = self._check_logs_lightweight()
+ details['logs'] = logs_status
+ if logs_status.get('status') == 'CRITICAL':
+ critical_issues.append(logs_status.get('reason', 'Critical log errors'))
+ elif logs_status.get('status') == 'WARNING':
+ warning_issues.append(logs_status.get('reason', 'Log warnings'))
+
+ # Priority 7: Extras (Security, Certificates, Uptime)
+ security_status = self._check_security()
+ details['security'] = security_status
+ if security_status.get('status') == 'WARNING':
+ warning_issues.append(security_status.get('reason', 'Security issue'))
+
+ # Determine overall status
+ if critical_issues:
+ overall = 'CRITICAL'
+ summary = '; '.join(critical_issues[:3]) # Top 3 critical issues
+ elif warning_issues:
+ overall = 'WARNING'
+ summary = '; '.join(warning_issues[:3]) # Top 3 warnings
+ else:
+ overall = 'OK'
+ summary = 'All systems operational'
+
+ return {
+ 'overall': overall,
+ 'summary': summary,
+ 'details': details,
+ 'timestamp': datetime.now().isoformat()
+ }
+
+ def _check_cpu_with_hysteresis(self) -> Dict[str, Any]:
+ """
+ Check CPU with hysteresis to avoid flapping alerts.
+ Requires sustained high usage before triggering.
+ """
+ try:
+ # Get CPU usage (1 second sample to minimize impact)
+ cpu_percent = psutil.cpu_percent(interval=1)
+ current_time = time.time()
+
+ # Track state history
+ state_key = 'cpu_usage'
+ self.state_history[state_key].append({
+ 'value': cpu_percent,
+ 'time': current_time
+ })
+
+ # Keep only recent history (last 5 minutes)
+ self.state_history[state_key] = [
+ entry for entry in self.state_history[state_key]
+ if current_time - entry['time'] < 300
+ ]
+
+ # Check for sustained high usage
+ critical_duration = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['value'] >= self.CPU_CRITICAL and
+ current_time - entry['time'] <= self.CPU_CRITICAL_DURATION
+ )
+
+ warning_duration = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['value'] >= self.CPU_WARNING and
+ current_time - entry['time'] <= self.CPU_WARNING_DURATION
+ )
+
+ recovery_duration = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['value'] < self.CPU_RECOVERY and
+ current_time - entry['time'] <= self.CPU_RECOVERY_DURATION
+ )
+
+ # Determine status with hysteresis
+ if critical_duration >= 2: # 2+ readings in critical range
+ status = 'CRITICAL'
+ reason = f'CPU >{self.CPU_CRITICAL}% for {self.CPU_CRITICAL_DURATION}s'
+ elif warning_duration >= 2 and recovery_duration < 2:
+ status = 'WARNING'
+ reason = f'CPU >{self.CPU_WARNING}% for {self.CPU_WARNING_DURATION}s'
+ else:
+ status = 'OK'
+ reason = None
+
+ # Get temperature if available (checked once per minute max)
+ temp_status = self._check_cpu_temperature()
+
+ result = {
+ 'status': status,
+ 'usage': round(cpu_percent, 1),
+ 'cores': psutil.cpu_count()
+ }
+
+ if reason:
+ result['reason'] = reason
+
+ if temp_status:
+ result['temperature'] = temp_status
+ if temp_status.get('status') == 'CRITICAL':
+ result['status'] = 'CRITICAL'
+ result['reason'] = temp_status.get('reason')
+ elif temp_status.get('status') == 'WARNING' and status == 'OK':
+ result['status'] = 'WARNING'
+ result['reason'] = temp_status.get('reason')
+
+ return result
+
+ except Exception as e:
+ return {'status': 'UNKNOWN', 'reason': f'CPU check failed: {str(e)}'}
+
+ def _check_cpu_temperature(self) -> Dict[str, Any]:
+ """Check CPU temperature (cached, max 1 check per minute)"""
+ cache_key = 'cpu_temp'
+ current_time = time.time()
+
+ # Check cache
+ if cache_key in self.last_check_times:
+ if current_time - self.last_check_times[cache_key] < 60:
+ return self.cached_results.get(cache_key, {})
+
+ try:
+ # Try lm-sensors first
+ result = subprocess.run(
+ ['sensors', '-A', '-u'],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode == 0:
+ temps = []
+ for line in result.stdout.split('\n'):
+ if 'temp' in line.lower() and '_input' in line:
+ try:
+ temp = float(line.split(':')[1].strip())
+ temps.append(temp)
+ except:
+ continue
+
+ if temps:
+ max_temp = max(temps)
+
+ if max_temp >= self.TEMP_CRITICAL:
+ status = 'CRITICAL'
+ reason = f'CPU temperature {max_temp}°C ≥{self.TEMP_CRITICAL}°C'
+ elif max_temp >= self.TEMP_WARNING:
+ status = 'WARNING'
+ reason = f'CPU temperature {max_temp}°C ≥{self.TEMP_WARNING}°C'
+ else:
+ status = 'OK'
+ reason = None
+
+ temp_result = {
+ 'status': status,
+ 'value': round(max_temp, 1),
+ 'unit': '°C'
+ }
+ if reason:
+ temp_result['reason'] = reason
+
+ self.cached_results[cache_key] = temp_result
+ self.last_check_times[cache_key] = current_time
+ return temp_result
+
+ # If sensors not available, return UNKNOWN (doesn't penalize)
+ unknown_result = {'status': 'UNKNOWN', 'reason': 'No temperature sensors available'}
+ self.cached_results[cache_key] = unknown_result
+ self.last_check_times[cache_key] = current_time
+ return unknown_result
+
+ except Exception:
+ unknown_result = {'status': 'UNKNOWN', 'reason': 'Temperature check unavailable'}
+ self.cached_results[cache_key] = unknown_result
+ self.last_check_times[cache_key] = current_time
+ return unknown_result
+
+ def _check_memory_comprehensive(self) -> Dict[str, Any]:
+ """Check memory including RAM and swap with sustained thresholds"""
+ try:
+ memory = psutil.virtual_memory()
+ swap = psutil.swap_memory()
+ current_time = time.time()
+
+ mem_percent = memory.percent
+ swap_percent = swap.percent if swap.total > 0 else 0
+ swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
+
+ # Track memory state
+ state_key = 'memory_usage'
+ self.state_history[state_key].append({
+ 'mem_percent': mem_percent,
+ 'swap_percent': swap_percent,
+ 'swap_vs_ram': swap_vs_ram,
+ 'time': current_time
+ })
+
+ # Keep only recent history
+ self.state_history[state_key] = [
+ entry for entry in self.state_history[state_key]
+ if current_time - entry['time'] < 600
+ ]
+
+ # Check sustained high memory
+ mem_critical = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['mem_percent'] >= self.MEMORY_CRITICAL and
+ current_time - entry['time'] <= self.MEMORY_DURATION
+ )
+
+ mem_warning = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['mem_percent'] >= self.MEMORY_WARNING and
+ current_time - entry['time'] <= self.MEMORY_DURATION
+ )
+
+ # Check swap usage
+ swap_critical = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['swap_vs_ram'] > self.SWAP_CRITICAL_PERCENT and
+ current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
+ )
+
+ swap_warning = sum(
+ 1 for entry in self.state_history[state_key]
+ if entry['swap_percent'] > 0 and
+ current_time - entry['time'] <= self.SWAP_WARNING_DURATION
+ )
+
+ # Determine status
+ if mem_critical >= 2:
+ status = 'CRITICAL'
+ reason = f'RAM >{self.MEMORY_CRITICAL}% for {self.MEMORY_DURATION}s'
+ elif swap_critical >= 2:
+ status = 'CRITICAL'
+ reason = f'Swap >{self.SWAP_CRITICAL_PERCENT}% of RAM for {self.SWAP_CRITICAL_DURATION}s'
+ elif mem_warning >= 2:
+ status = 'WARNING'
+ reason = f'RAM >{self.MEMORY_WARNING}% for {self.MEMORY_DURATION}s'
+ elif swap_warning >= 2:
+ status = 'WARNING'
+ reason = f'Swap active for >{self.SWAP_WARNING_DURATION}s'
+ else:
+ status = 'OK'
+ reason = None
+
+ result = {
+ 'status': status,
+ 'ram_percent': round(mem_percent, 1),
+ 'ram_available_gb': round(memory.available / (1024**3), 2),
+ 'swap_percent': round(swap_percent, 1),
+ 'swap_used_gb': round(swap.used / (1024**3), 2)
+ }
+
+ if reason:
+ result['reason'] = reason
+
+ return result
+
+ except Exception as e:
+ return {'status': 'UNKNOWN', 'reason': f'Memory check failed: {str(e)}'}
+
+ def _check_storage_comprehensive(self) -> Dict[str, Any]:
+ """
+ Comprehensive storage check including filesystems, mount points,
+ LVM, and Proxmox storages.
+ """
+ storage_results = {}
+
+ # Check critical filesystems
+ critical_mounts = ['/', '/var', '/var/lib/vz']
+
+ for mount_point in critical_mounts:
+ if os.path.exists(mount_point):
+ fs_status = self._check_filesystem(mount_point)
+ storage_results[mount_point] = fs_status
+
+ # Check all mounted filesystems
+ try:
+ partitions = psutil.disk_partitions()
+ for partition in partitions:
+ if partition.mountpoint not in critical_mounts:
+ try:
+ fs_status = self._check_filesystem(partition.mountpoint)
+ storage_results[partition.mountpoint] = fs_status
+ except PermissionError:
+ continue
+ except Exception as e:
+ storage_results['partitions_error'] = {
+ 'status': 'WARNING',
+ 'reason': f'Could not enumerate partitions: {str(e)}'
+ }
+
+ # Check LVM (especially local-lvm)
+ lvm_status = self._check_lvm()
+ if lvm_status:
+ storage_results['lvm'] = lvm_status
+
+ # Check Proxmox storages
+ pve_storages = self._check_proxmox_storages()
+ if pve_storages:
+ storage_results.update(pve_storages)
+
+ return storage_results
+
+ def _check_filesystem(self, mount_point: str) -> Dict[str, Any]:
+ """Check individual filesystem for space and mount status"""
+ try:
+ # Check if mounted
+ result = subprocess.run(
+ ['mountpoint', '-q', mount_point],
+ capture_output=True,
+ timeout=2
+ )
+
+ if result.returncode != 0:
+ return {
+ 'status': 'CRITICAL',
+ 'reason': f'Not mounted'
+ }
+
+ # Check if read-only
+ with open('/proc/mounts', 'r') as f:
+ for line in f:
+ parts = line.split()
+ if len(parts) >= 4 and parts[1] == mount_point:
+ options = parts[3].split(',')
+ if 'ro' in options:
+ return {
+ 'status': 'CRITICAL',
+ 'reason': 'Mounted read-only'
+ }
+
+ # Check disk usage
+ usage = psutil.disk_usage(mount_point)
+ percent = usage.percent
+
+ if percent >= self.STORAGE_CRITICAL:
+ status = 'CRITICAL'
+ reason = f'{percent:.1f}% full (≥{self.STORAGE_CRITICAL}%)'
+ elif percent >= self.STORAGE_WARNING:
+ status = 'WARNING'
+ reason = f'{percent:.1f}% full (≥{self.STORAGE_WARNING}%)'
+ else:
+ status = 'OK'
+ reason = None
+
+ result = {
+ 'status': status,
+ 'usage_percent': round(percent, 1),
+ 'free_gb': round(usage.free / (1024**3), 2),
+ 'total_gb': round(usage.total / (1024**3), 2)
+ }
+
+ if reason:
+ result['reason'] = reason
+
+ return result
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'Check failed: {str(e)}'
+ }
+
+ def _check_lvm(self) -> Dict[str, Any]:
+ """Check LVM volumes, especially local-lvm"""
+ try:
+ result = subprocess.run(
+ ['lvs', '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
+ capture_output=True,
+ text=True,
+ timeout=3
+ )
+
+ if result.returncode != 0:
+ return {
+ 'status': 'WARNING',
+ 'reason': 'LVM not available or no volumes'
+ }
+
+ volumes = []
+ local_lvm_found = False
+
+ for line in result.stdout.strip().split('\n'):
+ if line.strip():
+ parts = line.split()
+ if len(parts) >= 2:
+ lv_name = parts[0].strip()
+ vg_name = parts[1].strip()
+ volumes.append(f'{vg_name}/{lv_name}')
+
+ if 'local-lvm' in lv_name or 'local-lvm' in vg_name:
+ local_lvm_found = True
+
+ if not local_lvm_found and volumes:
+ return {
+ 'status': 'CRITICAL',
+ 'reason': 'local-lvm volume not found',
+ 'volumes': volumes
+ }
+
+ return {
+ 'status': 'OK',
+ 'volumes': volumes
+ }
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'LVM check failed: {str(e)}'
+ }
+
+ def _check_proxmox_storages(self) -> Dict[str, Any]:
+ """Check Proxmox-specific storages (NFS, CIFS, PBS)"""
+ storages = {}
+
+ try:
+ # Read Proxmox storage configuration
+ if os.path.exists('/etc/pve/storage.cfg'):
+ with open('/etc/pve/storage.cfg', 'r') as f:
+ current_storage = None
+ storage_type = None
+
+ for line in f:
+ line = line.strip()
+
+ if line.startswith('dir:') or line.startswith('nfs:') or \
+ line.startswith('cifs:') or line.startswith('pbs:'):
+ parts = line.split(':', 1)
+ storage_type = parts[0]
+ current_storage = parts[1].strip()
+ elif line.startswith('path ') and current_storage:
+ path = line.split(None, 1)[1]
+
+ if storage_type == 'dir':
+ if os.path.exists(path):
+ storages[f'storage_{current_storage}'] = {
+ 'status': 'OK',
+ 'type': 'dir',
+ 'path': path
+ }
+ else:
+ storages[f'storage_{current_storage}'] = {
+ 'status': 'CRITICAL',
+ 'reason': 'Directory does not exist',
+ 'type': 'dir',
+ 'path': path
+ }
+
+ current_storage = None
+ storage_type = None
+ except Exception as e:
+ storages['pve_storage_config'] = {
+ 'status': 'WARNING',
+ 'reason': f'Could not read storage config: {str(e)}'
+ }
+
+ return storages
+
+ def _check_disks_io(self) -> Dict[str, Any]:
+ """Check disk I/O errors from dmesg (lightweight)"""
+ disks = {}
+ current_time = time.time()
+
+ try:
+ # Only check dmesg for recent errors (last 2 seconds of kernel log)
+ result = subprocess.run(
+ ['dmesg', '-T', '--level=err,warn', '--since', '5 minutes ago'],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode == 0:
+ io_errors = defaultdict(int)
+
+ for line in result.stdout.split('\n'):
+ line_lower = line.lower()
+ if any(keyword in line_lower for keyword in ['i/o error', 'ata error', 'scsi error']):
+ # Extract disk name
+ for part in line.split():
+ if part.startswith('sd') or part.startswith('nvme') or part.startswith('hd'):
+ disk_name = part.rstrip(':,')
+ io_errors[disk_name] += 1
+
+ # Track in history
+ self.io_error_history[disk_name].append(current_time)
+
+ # Clean old history (keep last 5 minutes)
+ for disk in list(self.io_error_history.keys()):
+ self.io_error_history[disk] = [
+ t for t in self.io_error_history[disk]
+ if current_time - t < 300
+ ]
+
+ error_count = len(self.io_error_history[disk])
+
+ if error_count >= 3:
+ disks[f'/dev/{disk}'] = {
+ 'status': 'CRITICAL',
+ 'reason': f'{error_count} I/O errors in 5 minutes'
+ }
+ elif error_count >= 1:
+ disks[f'/dev/{disk}'] = {
+ 'status': 'WARNING',
+ 'reason': f'{error_count} I/O error(s) in 5 minutes'
+ }
+
+ # If no errors found, report OK
+ if not disks:
+ disks['status'] = 'OK'
+
+ return disks
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'Disk I/O check failed: {str(e)}'
+ }
+
+ def _check_network_comprehensive(self) -> Dict[str, Any]:
+ """Check network interfaces, bridges, and connectivity"""
+ try:
+ issues = []
+ interface_details = {}
+
+ # Check interface status
+ net_if_stats = psutil.net_if_stats()
+ net_io = psutil.net_io_counters(pernic=True)
+ current_time = time.time()
+
+ for interface, stats in net_if_stats.items():
+ if interface == 'lo':
+ continue
+
+ # Check if interface is down (excluding administratively down)
+ if not stats.isup:
+ # Check if it's a bridge or important interface
+ if interface.startswith('vmbr') or interface.startswith('eth') or interface.startswith('ens'):
+ issues.append(f'{interface} is DOWN')
+ interface_details[interface] = {
+ 'status': 'CRITICAL',
+ 'reason': 'Interface DOWN'
+ }
+ continue
+
+ # Check bridge traffic (if no traffic for 10 minutes)
+ if interface.startswith('vmbr') and interface in net_io:
+ io_stats = net_io[interface]
+
+ # Initialize baseline if not exists
+ if interface not in self.network_baseline:
+ self.network_baseline[interface] = {
+ 'rx_bytes': io_stats.bytes_recv,
+ 'tx_bytes': io_stats.bytes_sent,
+ 'time': current_time
+ }
+ else:
+ baseline = self.network_baseline[interface]
+ time_diff = current_time - baseline['time']
+
+ if time_diff >= self.NETWORK_INACTIVE_DURATION:
+ rx_diff = io_stats.bytes_recv - baseline['rx_bytes']
+ tx_diff = io_stats.bytes_sent - baseline['tx_bytes']
+
+ if rx_diff == 0 and tx_diff == 0:
+ issues.append(f'{interface} no traffic for 10+ minutes')
+ interface_details[interface] = {
+ 'status': 'WARNING',
+ 'reason': 'No traffic for 10+ minutes'
+ }
+
+ # Update baseline
+ self.network_baseline[interface] = {
+ 'rx_bytes': io_stats.bytes_recv,
+ 'tx_bytes': io_stats.bytes_sent,
+ 'time': current_time
+ }
+
+ # Check gateway/DNS latency (lightweight, cached)
+ latency_status = self._check_network_latency()
+ if latency_status.get('status') != 'OK':
+ issues.append(latency_status.get('reason', 'Network latency issue'))
+ interface_details['connectivity'] = latency_status
+
+ # Determine overall network status
+ if any('CRITICAL' in str(detail.get('status')) for detail in interface_details.values()):
+ status = 'CRITICAL'
+ reason = '; '.join(issues[:2])
+ elif issues:
+ status = 'WARNING'
+ reason = '; '.join(issues[:2])
+ else:
+ status = 'OK'
+ reason = None
+
+ result = {'status': status}
+ if reason:
+ result['reason'] = reason
+ if interface_details:
+ result['interfaces'] = interface_details
+
+ return result
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'Network check failed: {str(e)}'
+ }
+
+ def _check_network_latency(self) -> Dict[str, Any]:
+ """Check network latency to gateway/DNS (cached, max 1 check per minute)"""
+ cache_key = 'network_latency'
+ current_time = time.time()
+
+ # Check cache
+ if cache_key in self.last_check_times:
+ if current_time - self.last_check_times[cache_key] < 60:
+ return self.cached_results.get(cache_key, {'status': 'OK'})
+
+ try:
+ # Ping default gateway or 1.1.1.1
+ result = subprocess.run(
+ ['ping', '-c', '1', '-W', '1', '1.1.1.1'],
+ capture_output=True,
+ text=True,
+ timeout=self.NETWORK_TIMEOUT
+ )
+
+ if result.returncode == 0:
+ # Extract latency
+ for line in result.stdout.split('\n'):
+ if 'time=' in line:
+ try:
+ latency_str = line.split('time=')[1].split()[0]
+ latency = float(latency_str)
+
+ if latency > self.NETWORK_LATENCY_CRITICAL:
+ status = 'CRITICAL'
+ reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_CRITICAL}ms'
+ elif latency > self.NETWORK_LATENCY_WARNING:
+ status = 'WARNING'
+ reason = f'Latency {latency:.1f}ms >{self.NETWORK_LATENCY_WARNING}ms'
+ else:
+ status = 'OK'
+ reason = None
+
+ latency_result = {
+ 'status': status,
+ 'latency_ms': round(latency, 1)
+ }
+ if reason:
+ latency_result['reason'] = reason
+
+ self.cached_results[cache_key] = latency_result
+ self.last_check_times[cache_key] = current_time
+ return latency_result
+ except:
+ pass
+
+ # Ping failed
+ packet_loss_result = {
+ 'status': 'CRITICAL',
+ 'reason': 'Packet loss or timeout'
+ }
+ self.cached_results[cache_key] = packet_loss_result
+ self.last_check_times[cache_key] = current_time
+ return packet_loss_result
+
+ except Exception as e:
+ error_result = {
+ 'status': 'WARNING',
+ 'reason': f'Latency check failed: {str(e)}'
+ }
+ self.cached_results[cache_key] = error_result
+ self.last_check_times[cache_key] = current_time
+ return error_result
+
+ def _check_vms_cts(self) -> Dict[str, Any]:
+ """Check VM and CT status for unexpected stops"""
+ try:
+ issues = []
+ vm_details = {}
+
+ # Check VMs
+ try:
+ result = subprocess.run(
+ ['qm', 'list'],
+ capture_output=True,
+ text=True,
+ timeout=3
+ )
+
+ if result.returncode == 0:
+ for line in result.stdout.strip().split('\n')[1:]:
+ if line.strip():
+ parts = line.split()
+ if len(parts) >= 3:
+ vmid = parts[0]
+ vm_status = parts[2]
+
+ if vm_status == 'stopped':
+ # Check if unexpected (this is simplified, would need autostart config)
+ vm_details[f'vm_{vmid}'] = {
+ 'status': 'WARNING',
+ 'reason': 'VM stopped'
+ }
+ issues.append(f'VM {vmid} stopped')
+ except Exception as e:
+ vm_details['vms_check'] = {
+ 'status': 'WARNING',
+ 'reason': f'Could not check VMs: {str(e)}'
+ }
+
+ # Check CTs
+ try:
+ result = subprocess.run(
+ ['pct', 'list'],
+ capture_output=True,
+ text=True,
+ timeout=3
+ )
+
+ if result.returncode == 0:
+ for line in result.stdout.strip().split('\n')[1:]:
+ if line.strip():
+ parts = line.split()
+ if len(parts) >= 2:
+ ctid = parts[0]
+ ct_status = parts[1]
+
+ if ct_status == 'stopped':
+ vm_details[f'ct_{ctid}'] = {
+ 'status': 'WARNING',
+ 'reason': 'CT stopped'
+ }
+ issues.append(f'CT {ctid} stopped')
+ except Exception as e:
+ vm_details['cts_check'] = {
+ 'status': 'WARNING',
+ 'reason': f'Could not check CTs: {str(e)}'
+ }
+
+ # Determine overall status
+ if issues:
+ status = 'WARNING'
+ reason = '; '.join(issues[:3])
+ else:
+ status = 'OK'
+ reason = None
+
+ result = {'status': status}
+ if reason:
+ result['reason'] = reason
+ if vm_details:
+ result['details'] = vm_details
+
+ return result
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'VM/CT check failed: {str(e)}'
+ }
+
+ def _check_pve_services(self) -> Dict[str, Any]:
+ """Check critical Proxmox services"""
+ try:
+ failed_services = []
+
+ for service in self.PVE_SERVICES:
+ try:
+ result = subprocess.run(
+ ['systemctl', 'is-active', service],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode != 0 or result.stdout.strip() != 'active':
+ failed_services.append(service)
+ except Exception:
+ failed_services.append(service)
+
+ if failed_services:
+ return {
+ 'status': 'CRITICAL',
+ 'reason': f'Services inactive: {", ".join(failed_services)}',
+ 'failed': failed_services
+ }
+
+ return {'status': 'OK'}
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'Service check failed: {str(e)}'
+ }
+
+ def _check_logs_lightweight(self) -> Dict[str, Any]:
+ """Lightweight log analysis (cached, checked every 5 minutes)"""
+ cache_key = 'logs_analysis'
+ current_time = time.time()
+
+ # Check cache
+ if cache_key in self.last_check_times:
+ if current_time - self.last_check_times[cache_key] < self.LOG_CHECK_INTERVAL:
+ return self.cached_results.get(cache_key, {'status': 'OK'})
+
+ try:
+ # Check journalctl for recent errors and warnings
+ result = subprocess.run(
+ ['journalctl', '--since', '5 minutes ago', '--no-pager', '-p', 'warning'],
+ capture_output=True,
+ text=True,
+ timeout=3
+ )
+
+ if result.returncode == 0:
+ lines = result.stdout.strip().split('\n')
+
+ errors_5m = 0
+ warnings_5m = 0
+ critical_keywords_found = []
+
+ for line in lines:
+ line_lower = line.lower()
+
+ # Check for critical keywords
+ for keyword in self.CRITICAL_LOG_KEYWORDS:
+ if keyword.lower() in line_lower:
+ critical_keywords_found.append(keyword)
+ errors_5m += 1
+ break
+ else:
+ # Count errors and warnings
+ if 'error' in line_lower or 'critical' in line_lower or 'fatal' in line_lower:
+ errors_5m += 1
+ elif 'warning' in line_lower or 'warn' in line_lower:
+ warnings_5m += 1
+
+ # Determine status
+ if critical_keywords_found:
+ status = 'CRITICAL'
+ reason = f'Critical errors: {", ".join(set(critical_keywords_found[:3]))}'
+ elif errors_5m >= self.LOG_ERRORS_CRITICAL:
+ status = 'CRITICAL'
+ reason = f'{errors_5m} errors in 5 minutes (≥{self.LOG_ERRORS_CRITICAL})'
+ elif warnings_5m >= self.LOG_WARNINGS_CRITICAL:
+ status = 'CRITICAL'
+ reason = f'{warnings_5m} warnings in 5 minutes (≥{self.LOG_WARNINGS_CRITICAL})'
+ elif errors_5m >= self.LOG_ERRORS_WARNING:
+ status = 'WARNING'
+ reason = f'{errors_5m} errors in 5 minutes'
+ elif warnings_5m >= self.LOG_WARNINGS_WARNING:
+ status = 'WARNING'
+ reason = f'{warnings_5m} warnings in 5 minutes'
+ else:
+ status = 'OK'
+ reason = None
+
+ log_result = {
+ 'status': status,
+ 'errors_5m': errors_5m,
+ 'warnings_5m': warnings_5m
+ }
+ if reason:
+ log_result['reason'] = reason
+
+ self.cached_results[cache_key] = log_result
+ self.last_check_times[cache_key] = current_time
+ return log_result
+
+ ok_result = {'status': 'OK'}
+ self.cached_results[cache_key] = ok_result
+ self.last_check_times[cache_key] = current_time
+ return ok_result
+
+ except Exception as e:
+ error_result = {
+ 'status': 'WARNING',
+ 'reason': f'Log check failed: {str(e)}'
+ }
+ self.cached_results[cache_key] = error_result
+ self.last_check_times[cache_key] = current_time
+ return error_result
+
+ def _check_security(self) -> Dict[str, Any]:
+ """Check security-related items (fail2ban, certificates, uptime)"""
+ try:
+ issues = []
+
+ # Check fail2ban
+ try:
+ result = subprocess.run(
+ ['systemctl', 'is-active', 'fail2ban'],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode != 0 or result.stdout.strip() != 'active':
+ issues.append('fail2ban inactive')
+ except Exception:
+ pass
+
+ # Check uptime (warning if >180 days)
+ try:
+ uptime_seconds = time.time() - psutil.boot_time()
+ uptime_days = uptime_seconds / 86400
+
+ if uptime_days > 180:
+ issues.append(f'Uptime {int(uptime_days)} days (>180)')
+ except Exception:
+ pass
+
+ # Check SSL certificates (cached, checked once per day)
+ cert_status = self._check_certificates()
+ if cert_status.get('status') != 'OK':
+ issues.append(cert_status.get('reason', 'Certificate issue'))
+
+ if issues:
+ return {
+ 'status': 'WARNING',
+ 'reason': '; '.join(issues[:2])
+ }
+
+ return {'status': 'OK'}
+
+ except Exception as e:
+ return {
+ 'status': 'WARNING',
+ 'reason': f'Security check failed: {str(e)}'
+ }
+
+ def _check_certificates(self) -> Dict[str, Any]:
+ """Check SSL certificate expiration (cached, checked once per day)"""
+ cache_key = 'certificates'
+ current_time = time.time()
+
+ # Check cache (24 hours)
+ if cache_key in self.last_check_times:
+ if current_time - self.last_check_times[cache_key] < 86400:
+ return self.cached_results.get(cache_key, {'status': 'OK'})
+
+ try:
+ # Check PVE certificate
+ cert_path = '/etc/pve/local/pve-ssl.pem'
+
+ if os.path.exists(cert_path):
+ result = subprocess.run(
+ ['openssl', 'x509', '-enddate', '-noout', '-in', cert_path],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode == 0:
+ # Parse expiration date
+ date_str = result.stdout.strip().replace('notAfter=', '')
+
+ try:
+ from datetime import datetime
+ exp_date = datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
+ days_until_expiry = (exp_date - datetime.now()).days
+
+ if days_until_expiry < 0:
+ status = 'CRITICAL'
+ reason = 'Certificate expired'
+ elif days_until_expiry < 15:
+ status = 'WARNING'
+ reason = f'Certificate expires in {days_until_expiry} days'
+ else:
+ status = 'OK'
+ reason = None
+
+ cert_result = {'status': status}
+ if reason:
+ cert_result['reason'] = reason
+
+ self.cached_results[cache_key] = cert_result
+ self.last_check_times[cache_key] = current_time
+ return cert_result
+ except Exception:
+ pass
+
+ ok_result = {'status': 'OK'}
+ self.cached_results[cache_key] = ok_result
+ self.last_check_times[cache_key] = current_time
+ return ok_result
+
+ except Exception:
+ ok_result = {'status': 'OK'}
+ self.cached_results[cache_key] = ok_result
+ self.last_check_times[cache_key] = current_time
+ return ok_result
+
+
+# Global instance
+health_monitor = HealthMonitor()
diff --git a/AppImage/types/hardware.ts b/AppImage/types/hardware.ts
index 6907646..c31de3c 100644
--- a/AppImage/types/hardware.ts
+++ b/AppImage/types/hardware.ts
@@ -33,6 +33,13 @@ export interface StorageDevice {
rotation_rate?: number | string
form_factor?: string
sata_version?: string
+ pcie_gen?: string // e.g., "PCIe 4.0"
+ pcie_width?: string // e.g., "x4"
+ pcie_max_gen?: string // Maximum supported PCIe generation
+ pcie_max_width?: string // Maximum supported PCIe lanes
+ sas_version?: string // e.g., "SAS-3"
+ sas_speed?: string // e.g., "12Gb/s"
+ link_speed?: string // Generic link speed info
}
export interface PCIDevice {