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. +

+
+ +
+ + +
+ +

You can always enable this later in Settings

+
+ ) : ( +
+
+
+ +
+

Setup Authentication

+

Create a username and password to protect your dashboard

+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+
+ +
+ + +
+
+ )} +
+
+ ) +} 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 ( +
+
+
+
+
+ ProxMenux Logo { + 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

+
+
+ +
+
+ {error && ( +
+ +

{error}

+
+ )} + +
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + autoComplete="username" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + autoComplete="current-password" + /> +
+
+ + +
+
+ +

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() { ))}
-
+
- @@ -246,7 +311,7 @@ export function OnboardingCarousel() { ) : (
- {/* Don't show again */} - {currentSlide === slides.length - 1 && ( -
- -
- )} +
+ setDontShowAgain(checked as boolean)} + /> + +
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() {
)} -
+
setShowHealthModal(true)} + >
{/* Logo and Title */}
@@ -299,7 +304,10 @@ export function ProxmoxDashboard() { - +
e.stopPropagation()}> + +
{/* Mobile Actions */} @@ -317,11 +327,22 @@ export function ProxmoxDashboard() { {systemStatus.status} - - +
e.stopPropagation()}> + +
@@ -534,6 +555,8 @@ 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 && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{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. +

+
+ +
+ )} + + {!authEnabled && showSetupForm && ( +
+

Setup Authentication

+ +
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ + +
+
+ )} + + {authEnabled && ( +
+ + + {!showChangePassword && ( + + )} + + {showChangePassword && ( +
+

Change Password

+ +
+ +
+ + setCurrentPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setNewPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmNewPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ + +
+
+ )} + + +
+ )} +
+
+ + {/* 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 {