Merge branch 'MacRimi:main' into main

This commit is contained in:
cod378
2025-11-10 11:04:47 -03:00
committed by GitHub
17 changed files with 1813 additions and 703 deletions

View File

@@ -1,14 +1,34 @@
"use client" "use client"
import type React from "react"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button"
import { Loader2, CheckCircle2, AlertTriangle, XCircle, Activity } from "lucide-react" import {
Loader2,
CheckCircle2,
AlertTriangle,
XCircle,
Activity,
Cpu,
MemoryStick,
HardDrive,
Disc,
Network,
Box,
Settings,
FileText,
RefreshCw,
Shield,
X,
} from "lucide-react"
interface HealthDetail { interface CategoryCheck {
status: string status: string
reason?: string reason?: string
details?: any
[key: string]: any [key: string]: any
} }
@@ -16,7 +36,16 @@ interface HealthDetails {
overall: string overall: string
summary: string summary: string
details: { details: {
[category: string]: HealthDetail | { [key: string]: HealthDetail } cpu: CategoryCheck
memory: CategoryCheck
storage: CategoryCheck
disks: CategoryCheck
network: CategoryCheck
vms: CategoryCheck
services: CategoryCheck
logs: CategoryCheck
updates: CategoryCheck
security: CategoryCheck
} }
timestamp: string timestamp: string
} }
@@ -27,6 +56,19 @@ interface HealthStatusModalProps {
getApiUrl: (path: string) => string getApiUrl: (path: string) => string
} }
const CATEGORIES = [
{ key: "cpu", label: "CPU Usage & Temperature", Icon: Cpu },
{ key: "memory", label: "Memory & Swap", Icon: MemoryStick },
{ key: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
{ key: "disks", label: "Disk I/O & Errors", Icon: Disc },
{ key: "network", label: "Network Interfaces", Icon: Network },
{ key: "vms", label: "VMs & Containers", Icon: Box },
{ key: "services", label: "PVE Services", Icon: Settings },
{ key: "logs", label: "System Logs", Icon: FileText },
{ key: "updates", label: "System Updates", Icon: RefreshCw },
{ key: "security", label: "Security & Certificates", Icon: Shield },
]
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null) const [healthData, setHealthData] = useState<HealthDetails | null>(null)
@@ -58,74 +100,6 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
} }
} }
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 getStatusIcon = (status: string) => {
const statusUpper = status?.toUpperCase() const statusUpper = status?.toUpperCase()
switch (statusUpper) { switch (statusUpper) {
@@ -144,27 +118,106 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const statusUpper = status?.toUpperCase() const statusUpper = status?.toUpperCase()
switch (statusUpper) { switch (statusUpper) {
case "OK": case "OK":
return <Badge className="bg-green-500">Healthy</Badge> return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
case "WARNING": case "WARNING":
return <Badge className="bg-yellow-500">Warning</Badge> return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
case "CRITICAL": case "CRITICAL":
return <Badge className="bg-red-500">Critical</Badge> return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
default: default:
return <Badge>Unknown</Badge> return <Badge>Unknown</Badge>
} }
} }
const getHealthStats = () => {
if (!healthData?.details) {
return { total: 0, healthy: 0, warnings: 0, critical: 0 }
}
let healthy = 0
let warnings = 0
let critical = 0
CATEGORIES.forEach(({ key }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
if (categoryData) {
const status = categoryData.status?.toUpperCase()
if (status === "OK") healthy++
else if (status === "WARNING") warnings++
else if (status === "CRITICAL") critical++
}
})
return { total: CATEGORIES.length, healthy, warnings, critical }
}
const stats = getHealthStats() const stats = getHealthStats()
const groupedChecks = getGroupedChecks()
const handleCategoryClick = (categoryKey: string, status: string) => {
if (status === "OK") return // No navegar si está OK
onOpenChange(false) // Cerrar el modal
// Mapear categorías a tabs
const categoryToTab: Record<string, string> = {
storage: "storage",
disks: "storage",
network: "network",
vms: "vms",
logs: "logs",
hardware: "hardware",
services: "hardware",
}
const targetTab = categoryToTab[categoryKey]
if (targetTab) {
// Disparar evento para cambiar tab
const event = new CustomEvent("changeTab", { detail: { tab: targetTab } })
window.dispatchEvent(event)
}
}
const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => {
e.stopPropagation() // Prevent navigation
console.log("[v0] Dismissing error:", errorKey)
try {
const response = await fetch(getApiUrl("/api/health/acknowledge"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ error_key: errorKey }),
})
if (!response.ok) {
const errorData = await response.json()
console.error("[v0] Acknowledge failed:", errorData)
throw new Error(errorData.error || "Failed to acknowledge error")
}
const result = await response.json()
console.log("[v0] Acknowledge success:", result)
// Refresh health data
await fetchHealthDetails()
} catch (err) {
console.error("[v0] Error acknowledging:", err)
alert("Failed to dismiss error. Please try again.")
}
}
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <div className="flex items-center justify-between gap-3">
<Activity className="h-6 w-6" /> <DialogTitle className="flex items-center gap-2 flex-1">
System Health Status <Activity className="h-6 w-6" />
</DialogTitle> System Health Status
{healthData && <div className="ml-2">{getStatusBadge(healthData.overall)}</div>}
</DialogTitle>
</div>
<DialogDescription>Detailed health checks for all system components</DialogDescription> <DialogDescription>Detailed health checks for all system components</DialogDescription>
</DialogHeader> </DialogHeader>
@@ -182,82 +235,118 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
)} )}
{healthData && !loading && ( {healthData && !loading && (
<div className="space-y-6"> <div className="space-y-4">
{/* Overall Status Summary */} {/* Overall Stats Summary */}
<Card> <div className="grid grid-cols-4 gap-3 p-4 rounded-lg bg-muted/30 border">
<CardHeader> <div className="text-center">
<CardTitle className="flex items-center justify-between"> <div className="text-2xl font-bold">{stats.total}</div>
<span>Overall Status</span> <div className="text-xs text-muted-foreground">Total Checks</div>
{getStatusBadge(healthData.overall)} </div>
</CardTitle> <div className="text-center">
</CardHeader> <div className="text-2xl font-bold text-green-500">{stats.healthy}</div>
<CardContent> <div className="text-xs text-muted-foreground">Healthy</div>
{healthData.summary && <p className="text-sm text-muted-foreground mb-4">{healthData.summary}</p>} </div>
<div className="grid grid-cols-4 gap-4 text-center"> <div className="text-center">
<div> <div className="text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-2xl font-bold">{stats.total}</div> <div className="text-xs text-muted-foreground">Warnings</div>
<div className="text-sm text-muted-foreground">Total Checks</div> </div>
</div> <div className="text-center">
<div> <div className="text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-2xl font-bold text-green-500">{stats.healthy}</div> <div className="text-xs text-muted-foreground">Critical</div>
<div className="text-sm text-muted-foreground">Healthy</div> </div>
</div> </div>
<div>
<div className="text-2xl font-bold text-yellow-500">{stats.warnings}</div>
<div className="text-sm text-muted-foreground">Warnings</div>
</div>
<div>
<div className="text-2xl font-bold text-red-500">{stats.critical}</div>
<div className="text-sm text-muted-foreground">Critical</div>
</div>
</div>
</CardContent>
</Card>
{/* Grouped Health Checks */} {healthData.summary && healthData.summary !== "All systems operational" && (
{Object.entries(groupedChecks).map(([category, checks]) => ( <div className="text-sm p-3 rounded-lg bg-muted/20 border">
<Card key={category}> <span className="font-medium text-foreground">{healthData.summary}</span>
<CardHeader> </div>
<CardTitle className="text-lg">{category}</CardTitle> )}
</CardHeader>
<CardContent> <div className="space-y-2">
<div className="space-y-3"> {CATEGORIES.map(({ key, label, Icon }) => {
{checks.map((check, index) => ( const categoryData = healthData.details[key as keyof typeof healthData.details]
<div const status = categoryData?.status || "UNKNOWN"
key={`${category}-${index}`} const reason = categoryData?.reason
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-muted/50 transition-colors" const details = categoryData?.details
>
<div className="mt-0.5">{getStatusIcon(check.status)}</div> return (
<div className="flex-1 min-w-0"> <div
<div className="flex items-center justify-between gap-2"> key={key}
<p className="font-medium">{check.name}</p> onClick={() => handleCategoryClick(key, status)}
<Badge variant="outline" className="shrink-0"> className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
{check.status} status === "OK"
</Badge> ? "bg-green-500/5 border-green-500/20 hover:bg-green-500/10"
</div> : status === "WARNING"
{check.reason && <p className="text-sm text-muted-foreground mt-1">{check.reason}</p>} ? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer"
{check.details && ( : status === "CRITICAL"
<div className="text-xs text-muted-foreground mt-2 space-y-0.5"> ? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer"
{Object.entries(check.details).map(([key, value]) => { : "bg-muted/30 hover:bg-muted/50"
if (key === "status" || key === "reason" || typeof value === "object") return null }`}
return ( >
<div key={key} className="font-mono"> <div className="mt-0.5 flex-shrink-0 flex items-center gap-2">
{key}: {String(value)} <Icon className="h-4 w-4 text-muted-foreground" />
</div> {getStatusIcon(status)}
) </div>
})} <div className="flex-1 min-w-0">
</div> <div className="flex items-center justify-between gap-2 mb-1">
)} <p className="font-medium text-sm">{label}</p>
</div> <Badge
variant="outline"
className={`shrink-0 text-xs ${
status === "OK"
? "border-green-500 text-green-500 bg-green-500/5"
: status === "WARNING"
? "border-yellow-500 text-yellow-500 bg-yellow-500/5"
: status === "CRITICAL"
? "border-red-500 text-red-500 bg-red-500/5"
: ""
}`}
>
{status}
</Badge>
</div> </div>
))} {reason && <p className="text-xs text-muted-foreground mt-1">{reason}</p>}
{details && typeof details === "object" && (
<div className="mt-2 space-y-1">
{Object.entries(details).map(([detailKey, detailValue]: [string, any]) => {
if (typeof detailValue === "object" && detailValue !== null) {
return (
<div
key={detailKey}
className="flex items-start justify-between gap-2 text-xs pl-3 border-l-2 border-muted py-1"
>
<div className="flex-1">
<span className="font-medium">{detailKey}:</span>
{detailValue.reason && (
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
)}
</div>
{status !== "OK" && (
<Button
size="sm"
variant="outline"
className="h-6 px-2 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent"
onClick={(e) => handleAcknowledge(detailKey, e)}
>
<X className="h-3 w-3 mr-1" />
<span className="text-xs">Dismiss</span>
</Button>
)}
</div>
)
}
return null
})}
</div>
)}
</div>
</div> </div>
</CardContent> )
</Card> })}
))} </div>
{healthData.timestamp && ( {healthData.timestamp && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center pt-2">
Last updated: {new Date(healthData.timestamp).toLocaleString()} Last updated: {new Date(healthData.timestamp).toLocaleString()}
</div> </div>
)} )}

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts" import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
import { Loader2, TrendingUp, MemoryStick } from "lucide-react" import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile"
const TIMEFRAME_OPTIONS = [ const TIMEFRAME_OPTIONS = [
{ value: "hour", label: "1 Hour" }, { value: "hour", label: "1 Hour" },
@@ -69,6 +70,7 @@ export function NodeMetricsCharts() {
const [data, setData] = useState<NodeMetricsData[]>([]) const [data, setData] = useState<NodeMetricsData[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const isMobile = useIsMobile()
const [visibleLines, setVisibleLines] = useState({ const [visibleLines, setVisibleLines] = useState({
cpu: { cpu: true, load: true }, cpu: { cpu: true, load: true },
@@ -321,15 +323,15 @@ export function NodeMetricsCharts() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* CPU Usage + Load Average Chart */} {/* CPU Usage + Load Average Chart */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader className="px-4 md:px-6">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" /> <TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average CPU Usage & Load Average
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-0 md:px-6">
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}> <AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis <XAxis
dataKey="time" dataKey="time"
@@ -346,7 +348,9 @@ export function NodeMetricsCharts() {
stroke="currentColor" stroke="currentColor"
className="text-foreground" className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }} tick={{ fill: "currentColor", fontSize: 12 }}
label={{ value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }} label={
isMobile ? undefined : { value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }
}
domain={[0, "dataMax"]} domain={[0, "dataMax"]}
/> />
<YAxis <YAxis
@@ -355,7 +359,9 @@ export function NodeMetricsCharts() {
stroke="currentColor" stroke="currentColor"
className="text-foreground" className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }} tick={{ fill: "currentColor", fontSize: 12 }}
label={{ value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }} label={
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
}
domain={[0, "dataMax"]} domain={[0, "dataMax"]}
/> />
<Tooltip content={<CustomCpuTooltip />} /> <Tooltip content={<CustomCpuTooltip />} />
@@ -389,15 +395,15 @@ export function NodeMetricsCharts() {
{/* Memory Usage Chart */} {/* Memory Usage Chart */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader className="px-4 md:px-6">
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" /> <MemoryStick className="h-5 w-5 mr-2" />
Memory Usage Memory Usage
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-0 pr-2 md:px-6">
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}> <AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" /> <CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis <XAxis
dataKey="time" dataKey="time"
@@ -413,7 +419,9 @@ export function NodeMetricsCharts() {
stroke="currentColor" stroke="currentColor"
className="text-foreground" className="text-foreground"
tick={{ fill: "currentColor", fontSize: 12 }} tick={{ fill: "currentColor", fontSize: 12 }}
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }} label={
isMobile ? undefined : { value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }
}
domain={[0, "dataMax"]} domain={[0, "dataMax"]}
/> />
<Tooltip content={<CustomMemoryTooltip />} /> <Tooltip content={<CustomMemoryTooltip />} />

View File

@@ -55,7 +55,9 @@ interface FlaskSystemInfo {
hostname: string hostname: string
node_id: string node_id: string
uptime: string uptime: string
health_status: "healthy" | "warning" | "critical" health: {
status: "healthy" | "warning" | "critical"
}
} }
export function ProxmoxDashboard() { export function ProxmoxDashboard() {
@@ -96,8 +98,19 @@ export function ProxmoxDashboard() {
const uptimeValue = const uptimeValue =
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A" data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
const backendStatus = data.health?.status?.toUpperCase() || "OK"
let healthStatus: "healthy" | "warning" | "critical"
if (backendStatus === "CRITICAL") {
healthStatus = "critical"
} else if (backendStatus === "WARNING") {
healthStatus = "warning"
} else {
healthStatus = "healthy"
}
setSystemStatus({ setSystemStatus({
status: data.health_status || "healthy", status: healthStatus,
uptime: uptimeValue, uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname || "Unknown", serverName: data.hostname || "Unknown",
@@ -123,11 +136,13 @@ export function ProxmoxDashboard() {
// Siempre fetch inicial // Siempre fetch inicial
fetchSystemData() fetchSystemData()
// En overview: cada 30 segundos para actualización frecuente del estado de salud
// En otras tabs: cada 60 segundos para reducir carga
let interval: ReturnType<typeof setInterval> | null = null let interval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") { if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 9000) // Cambiado de 10000 a 9000ms interval = setInterval(fetchSystemData, 30000) // 30 segundos
} else { } else {
interval = setInterval(fetchSystemData, 61000) // Cambiado de 60000 a 61000ms interval = setInterval(fetchSystemData, 60000) // 60 segundos
} }
return () => { return () => {
@@ -135,6 +150,20 @@ export function ProxmoxDashboard() {
} }
}, [fetchSystemData, activeTab]) }, [fetchSystemData, activeTab])
useEffect(() => {
const handleChangeTab = (event: CustomEvent) => {
const { tab } = event.detail
if (tab) {
setActiveTab(tab)
}
}
window.addEventListener("changeTab", handleChangeTab as EventListener)
return () => {
window.removeEventListener("changeTab", handleChangeTab as EventListener)
}
}, [])
useEffect(() => { useEffect(() => {
if ( if (
systemStatus.serverName && systemStatus.serverName &&

View File

@@ -5,10 +5,16 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut } from "lucide-react" import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package } from "lucide-react"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup" import { TwoFactorSetup } from "./two-factor-setup"
interface ProxMenuxTool {
key: string
name: string
enabled: boolean
}
export function Settings() { export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false) const [totpEnabled, setTotpEnabled] = useState(false)
@@ -32,8 +38,12 @@ export function Settings() {
const [show2FADisable, setShow2FADisable] = useState(false) const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("") const [disable2FAPassword, setDisable2FAPassword] = useState("")
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [loadingTools, setLoadingTools] = useState(true)
useEffect(() => { useEffect(() => {
checkAuthStatus() checkAuthStatus()
loadProxmenuxTools()
}, []) }, [])
const checkAuthStatus = async () => { const checkAuthStatus = async () => {
@@ -47,6 +57,21 @@ export function Settings() {
} }
} }
const loadProxmenuxTools = async () => {
try {
const response = await fetch(getApiUrl("/api/proxmenux/installed-tools"))
const data = await response.json()
if (data.success) {
setProxmenuxTools(data.installed_tools || [])
}
} catch (err) {
console.error("Failed to load ProxMenux tools:", err)
} finally {
setLoadingTools(false)
}
}
const handleEnableAuth = async () => { const handleEnableAuth = async () => {
setError("") setError("")
setSuccess("") setSuccess("")
@@ -541,21 +566,45 @@ export function Settings() {
</CardContent> </CardContent>
</Card> </Card>
{/* About Section */} {/* ProxMenux Optimizations */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>About</CardTitle> <div className="flex items-center gap-2">
<CardDescription>ProxMenux Monitor information</CardDescription> <Wrench className="h-5 w-5 text-orange-500" />
<CardTitle>ProxMenux Optimizations</CardTitle>
</div>
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent>
<div className="flex justify-between"> {loadingTools ? (
<span className="text-muted-foreground">Version</span> <div className="flex items-center justify-center py-8">
<span className="font-medium">1.0.1</span> <div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
</div> </div>
<div className="flex justify-between"> ) : proxmenuxTools.length === 0 ? (
<span className="text-muted-foreground">Build</span> <div className="text-center py-8">
<span className="font-medium">Debian Package</span> <Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
</div> <p className="text-muted-foreground">No ProxMenux optimizations installed yet</p>
<p className="text-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Installed Tools</span>
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{proxmenuxTools.map((tool) => (
<div
key={tool.key}
className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border hover:bg-muted transition-colors"
>
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<span className="text-sm font-medium">{tool.name}</span>
</div>
))}
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -65,6 +65,7 @@ interface ProxmoxStorage {
used: number used: number
available: number available: number
percent: number percent: number
node: string // Added node property for detailed debug logging
} }
interface ProxmoxStorageData { interface ProxmoxStorageData {
@@ -101,27 +102,6 @@ export function StorageOverview() {
const data = await storageResponse.json() const data = await storageResponse.json()
const proxmoxData = await proxmoxResponse.json() const proxmoxData = await proxmoxResponse.json()
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) setStorageData(data)
setProxmoxStorage(proxmoxData) setProxmoxStorage(proxmoxData)
} catch (error) { } catch (error) {
@@ -417,24 +397,33 @@ export function StorageOverview() {
const diskHealthBreakdown = getDiskHealthBreakdown() const diskHealthBreakdown = getDiskHealthBreakdown()
const diskTypesBreakdown = getDiskTypesBreakdown() const diskTypesBreakdown = getDiskTypesBreakdown()
// Only sum storage that belongs to the current node or filter appropriately
const totalProxmoxUsed = const totalProxmoxUsed =
proxmoxStorage && proxmoxStorage.storage proxmoxStorage?.storage
? proxmoxStorage.storage .filter(
.filter( (storage) =>
(storage) => storage &&
storage && storage.name &&
storage.total > 0 && storage.status === "active" &&
storage.used >= 0 && // Added check for valid used value storage.total > 0 &&
storage.status && storage.used >= 0 &&
storage.status.toLowerCase() === "active", storage.available >= 0,
) )
.reduce((sum, storage) => sum + storage.used, 0) .reduce((sum, storage) => sum + storage.used, 0) || 0
: 0
// Convert storageData.total from TB to GB before calculating percentage const totalProxmoxCapacity =
const usagePercent = proxmoxStorage?.storage
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00" .filter(
(storage) =>
storage &&
storage.name &&
storage.status === "active" &&
storage.total > 0 &&
storage.used >= 0 &&
storage.available >= 0,
)
.reduce((sum, storage) => sum + storage.total, 0) || 0
const usagePercent = totalProxmoxCapacity > 0 ? ((totalProxmoxUsed / totalProxmoxCapacity) * 100).toFixed(2) : "0.00"
if (loading) { if (loading) {
return ( return (

View File

@@ -225,100 +225,87 @@ export function SystemOverview() {
const [storageData, setStorageData] = useState<StorageData | null>(null) const [storageData, setStorageData] = useState<StorageData | null>(null)
const [proxmoxStorageData, setProxmoxStorageData] = useState<ProxmoxStorageData | null>(null) const [proxmoxStorageData, setProxmoxStorageData] = useState<ProxmoxStorageData | null>(null)
const [networkData, setNetworkData] = useState<NetworkData | null>(null) const [networkData, setNetworkData] = useState<NetworkData | null>(null)
const [loading, setLoading] = useState(true) const [loadingStates, setLoadingStates] = useState({
system: true,
vms: true,
storage: true,
network: true,
})
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [networkTimeframe, setNetworkTimeframe] = useState("day") const [networkTimeframe, setNetworkTimeframe] = useState("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchAllData = async () => {
try { const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
setLoading(true) fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
setError(null) fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
setLoadingStates((prev) => ({ ...prev, storage: false })),
),
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
])
const systemResult = await fetchSystemData() if (!systemResult) {
setError("Flask server not available. Please ensure the server is running.")
if (!systemResult) { return
setError("Flask server not available. Please ensure the server is running.")
setLoading(false)
return
}
setSystemData(systemResult)
} catch (err) {
console.error("[v0] Error fetching system data:", err)
setError("Failed to connect to Flask server. Please check your connection.")
} finally {
setLoading(false)
} }
setSystemData(systemResult)
setVmData(vmResult)
setStorageData(storageResults[0])
setProxmoxStorageData(storageResults[1])
setNetworkData(networkResult)
setTimeout(async () => {
const refreshedSystemData = await fetchSystemData()
if (refreshedSystemData) {
setSystemData(refreshedSystemData)
}
}, 2000)
} }
fetchData() fetchAllData()
const systemInterval = setInterval(() => { const systemInterval = setInterval(async () => {
fetchSystemData().then((data) => { const data = await fetchSystemData()
if (data) setSystemData(data) if (data) setSystemData(data)
}) }, 9000)
}, 9000) // Cambiado de 10000 a 9000ms
const vmInterval = setInterval(async () => {
const data = await fetchVMData()
setVmData(data)
}, 59000)
const storageInterval = setInterval(async () => {
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
if (storage) setStorageData(storage)
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
}, 59000)
const networkInterval = setInterval(async () => {
const data = await fetchNetworkData()
if (data) setNetworkData(data)
}, 59000)
return () => { return () => {
clearInterval(systemInterval) clearInterval(systemInterval)
}
}, [])
useEffect(() => {
const fetchVMs = async () => {
const vmResult = await fetchVMData()
setVmData(vmResult)
}
fetchVMs()
const vmInterval = setInterval(fetchVMs, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(vmInterval) clearInterval(vmInterval)
}
}, [])
useEffect(() => {
const fetchStorage = async () => {
const storageResult = await fetchStorageData()
setStorageData(storageResult)
const proxmoxStorageResult = await fetchProxmoxStorageData()
setProxmoxStorageData(proxmoxStorageResult)
}
fetchStorage()
const storageInterval = setInterval(fetchStorage, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(storageInterval) clearInterval(storageInterval)
}
}, [])
useEffect(() => {
const fetchNetwork = async () => {
const networkResult = await fetchNetworkData()
setNetworkData(networkResult)
}
fetchNetwork()
const networkInterval = setInterval(fetchNetwork, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(networkInterval) clearInterval(networkInterval)
} }
}, []) }, [])
if (loading) { const isInitialLoading = loadingStates.system && !systemData
if (isInitialLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div> <div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div>
<div className="text-sm text-muted-foreground">Fetching real-time system data</div> <div className="text-sm text-muted-foreground">Fetching real-time system data</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i} className="bg-card border-border animate-pulse"> <Card key={i} className="bg-card border-border animate-pulse">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -386,12 +373,10 @@ export function SystemOverview() {
const formatStorage = (sizeInGB: number): string => { const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) { if (sizeInGB < 1) {
// Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB` return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB > 999) { } else if (sizeInGB > 999) {
return `${(sizeInGB / 1024).toFixed(2)} TB` return `${(sizeInGB / 1024).toFixed(2)} TB`
} else { } else {
// Between 1 and 999 GB, show in GB
return `${sizeInGB.toFixed(2)} GB` return `${sizeInGB.toFixed(2)} GB`
} }
} }
@@ -402,13 +387,10 @@ export function SystemOverview() {
const vmLxcStorages = proxmoxStorageData?.storage.filter( const vmLxcStorages = proxmoxStorageData?.storage.filter(
(s) => (s) =>
// Include only local storage types that can host VMs/LXCs
(s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") && (s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") &&
// Exclude network storage
s.type !== "nfs" && s.type !== "nfs" &&
s.type !== "cifs" && s.type !== "cifs" &&
s.type !== "iscsi" && s.type !== "iscsi" &&
// Exclude the "local" storage (used for ISOs/templates)
s.name !== "local", s.name !== "local",
) )
@@ -474,7 +456,6 @@ export function SystemOverview() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Key Metrics Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -524,34 +505,44 @@ export function SystemOverview() {
</Card> </Card>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM & LXC</CardTitle> <CardTitle className="text-foreground flex items-center">
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-5 w-5 mr-2" />
Active VM & LXC
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div> {loadingStates.vms ? (
<div className="mt-2 flex flex-wrap gap-1"> <div className="space-y-2 animate-pulse">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <div className="h-8 bg-muted rounded w-12"></div>
{vmStats.running} Running <div className="h-5 bg-muted rounded w-24"></div>
</Badge> <div className="h-4 bg-muted rounded w-32"></div>
{vmStats.stopped > 0 && ( </div>
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20"> ) : (
{vmStats.stopped} Stopped <>
</Badge> <div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
)} <div className="mt-2 flex flex-wrap gap-1">
</div> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
<p className="text-xs text-muted-foreground mt-2"> {vmStats.running} Running
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC </Badge>
</p> {vmStats.stopped > 0 && (
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
{vmStats.stopped} Stopped
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Node Metrics Charts */}
<NodeMetricsCharts /> <NodeMetricsCharts />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Storage Summary */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">
@@ -560,8 +551,45 @@ export function SystemOverview() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{storageData ? ( {loadingStates.storage ? (
<div className="space-y-4 animate-pulse">
<div className="h-6 bg-muted rounded w-full"></div>
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-2/3"></div>
</div>
) : storageData ? (
<div className="space-y-4"> <div className="space-y-4">
{(() => {
const totalCapacity = (vmLxcStorageTotal || 0) + (localStorage?.total || 0)
const totalUsed = (vmLxcStorageUsed || 0) + (localStorage?.used || 0)
const totalAvailable = (vmLxcStorageAvailable || 0) + (localStorage?.available || 0)
const totalPercent = totalCapacity > 0 ? (totalUsed / totalCapacity) * 100 : 0
return totalCapacity > 0 ? (
<div className="space-y-2 pb-4 border-b-2 border-border">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
<span className="text-lg font-bold text-foreground">{formatStorage(totalCapacity)}</span>
</div>
<Progress
value={totalPercent}
className="mt-2 h-3 [&>div]:bg-gradient-to-r [&>div]:from-blue-500 [&>div]:to-purple-500"
/>
<div className="flex justify-between items-center mt-1">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
Used: <span className="font-semibold text-foreground">{formatStorage(totalUsed)}</span>
</span>
<span className="text-xs text-muted-foreground">
Free: <span className="font-semibold text-green-500">{formatStorage(totalAvailable)}</span>
</span>
</div>
<span className="text-xs font-semibold text-muted-foreground">{totalPercent.toFixed(1)}%</span>
</div>
</div>
) : null
})()}
<div className="space-y-2 pb-3 border-b border-border"> <div className="space-y-2 pb-3 border-b border-border">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total Capacity:</span> <span className="text-sm text-muted-foreground">Total Capacity:</span>
@@ -637,7 +665,6 @@ export function SystemOverview() {
</CardContent> </CardContent>
</Card> </Card>
{/* Network Summary */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center justify-between"> <CardTitle className="text-foreground flex items-center justify-between">
@@ -660,7 +687,13 @@ export function SystemOverview() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{networkData ? ( {loadingStates.network ? (
<div className="space-y-4 animate-pulse">
<div className="h-6 bg-muted rounded w-full"></div>
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-2/3"></div>
</div>
) : networkData ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center pb-3 border-b border-border"> <div className="flex justify-between items-center pb-3 border-b border-border">
<span className="text-sm text-muted-foreground">Active Interfaces:</span> <span className="text-sm text-muted-foreground">Active Interfaces:</span>
@@ -731,7 +764,6 @@ export function SystemOverview() {
</Card> </Card>
</div> </div>
{/* System Information */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
@@ -764,7 +796,6 @@ export function SystemOverview() {
</CardContent> </CardContent>
</Card> </Card>
{/* System Health & Alerts */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground flex items-center"> <CardTitle className="text-foreground flex items-center">

View File

@@ -139,7 +139,7 @@ const fetcher = async (url: string) => {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(30000),
}) })
if (!response.ok) { if (!response.ok) {
@@ -267,6 +267,8 @@ export function VirtualMachines() {
refreshInterval: 23000, refreshInterval: 23000,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
dedupingInterval: 10000,
errorRetryCount: 2,
}) })
const [selectedVM, setSelectedVM] = useState<VMData | null>(null) const [selectedVM, setSelectedVM] = useState<VMData | null>(null)
@@ -287,27 +289,43 @@ export function VirtualMachines() {
if (!vmData) return if (!vmData) return
const lxcs = vmData.filter((vm) => vm.type === "lxc") const lxcs = vmData.filter((vm) => vm.type === "lxc")
if (lxcs.length === 0) return
const configs: Record<number, string> = {} const configs: Record<number, string> = {}
await Promise.all( const batchSize = 5
lxcs.map(async (lxc) => { for (let i = 0; i < lxcs.length; i += batchSize) {
try { const batch = lxcs.slice(i, i + batchSize)
const response = await fetch(`/api/vms/${lxc.vmid}`)
if (response.ok) {
const details = await response.json()
if (details.lxc_ip_info?.primary_ip) {
configs[lxc.vmid] = details.lxc_ip_info.primary_ip
} else if (details.config) {
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
}
}
} catch (error) {
console.error(`Error fetching config for LXC ${lxc.vmid}:`, error)
}
}),
)
setVmConfigs(configs) await Promise.all(
batch.map(async (lxc) => {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
const response = await fetch(`/api/vms/${lxc.vmid}`, {
signal: controller.signal,
})
clearTimeout(timeoutId)
if (response.ok) {
const details = await response.json()
if (details.lxc_ip_info?.primary_ip) {
configs[lxc.vmid] = details.lxc_ip_info.primary_ip
} else if (details.config) {
configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info)
}
}
} catch (error) {
console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`)
}
}),
)
setVmConfigs((prev) => ({ ...prev, ...configs }))
}
} }
fetchLXCIPs() fetchLXCIPs()

View File

@@ -0,0 +1,23 @@
"use client"
import { useEffect, useState } from "react"
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
// Check on mount
checkMobile()
// Listen for resize
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
return isMobile
}

View File

@@ -1,85 +0,0 @@
"use client"
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
export interface PollingIntervals {
storage: number
network: number
vms: number
hardware: number
}
// Default intervals in milliseconds
const DEFAULT_INTERVALS: PollingIntervals = {
storage: 60000, // 60 seconds
network: 60000, // 60 seconds
vms: 30000, // 30 seconds
hardware: 60000, // 60 seconds
}
const STORAGE_KEY = "proxmenux_polling_intervals"
interface PollingConfigContextType {
intervals: PollingIntervals
updateInterval: (key: keyof PollingIntervals, value: number) => void
}
const PollingConfigContext = createContext<PollingConfigContextType | undefined>(undefined)
export function PollingConfigProvider({ children }: { children: ReactNode }) {
const [intervals, setIntervals] = useState<PollingIntervals>(DEFAULT_INTERVALS)
// Load from localStorage on mount
useEffect(() => {
if (typeof window === "undefined") return
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
setIntervals({ ...DEFAULT_INTERVALS, ...parsed })
} catch (e) {
console.error("[v0] Failed to parse stored polling intervals:", e)
}
}
}, [])
const updateInterval = (key: keyof PollingIntervals, value: number) => {
setIntervals((prev) => {
const newIntervals = { ...prev, [key]: value }
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newIntervals))
}
return newIntervals
})
}
return <PollingConfigContext.Provider value={{ intervals, updateInterval }}>{children}</PollingConfigContext.Provider>
}
export function usePollingConfig() {
const context = useContext(PollingConfigContext)
if (!context) {
// During SSR or when provider is not available, return defaults
if (typeof window === "undefined") {
return {
intervals: DEFAULT_INTERVALS,
updateInterval: () => {},
}
}
throw new Error("usePollingConfig must be used within PollingConfigProvider")
}
return context
}
// Interval options for the UI (in milliseconds)
export const INTERVAL_OPTIONS = [
{ label: "10 seconds", value: 10000 },
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000 },
{ label: "10 minutes", value: 600000 },
{ label: "30 minutes", value: 1800000 },
{ label: "1 hour", value: 3600000 },
]

View File

@@ -1,6 +1,6 @@
{ {
"name": "proxmenux-monitor", "name": "proxmenux-monitor",
"version": "1.0.0", "version": "1.0.1",
"description": "Proxmox System Monitoring Dashboard", "description": "Proxmox System Monitoring Dashboard",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -81,7 +81,9 @@ 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/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/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/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found" cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
echo "📋 Adding translation support..." echo "📋 Adding translation support..."
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'

View File

@@ -1,9 +1,10 @@
""" """
Flask routes for health monitoring Flask routes for health monitoring with persistence support
""" """
from flask import Blueprint, jsonify from flask import Blueprint, jsonify, request
from health_monitor import health_monitor from health_monitor import health_monitor
from health_persistence import health_persistence
health_bp = Blueprint('health', __name__) health_bp = Blueprint('health', __name__)
@@ -29,11 +30,45 @@ def get_health_details():
def get_system_info(): def get_system_info():
""" """
Get lightweight system info for header display. Get lightweight system info for header display.
Returns: hostname, uptime, and cached health status. Returns: hostname, uptime, and health status with proper structure.
This is optimized for minimal server impact.
""" """
try: try:
info = health_monitor.get_system_info() info = health_monitor.get_system_info()
if 'health' in info:
status_map = {
'OK': 'healthy',
'WARNING': 'warning',
'CRITICAL': 'critical',
'UNKNOWN': 'warning'
}
current_status = info['health'].get('status', 'OK').upper()
info['health']['status'] = status_map.get(current_status, 'healthy')
return jsonify(info) return jsonify(info)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/acknowledge', methods=['POST'])
def acknowledge_error():
"""Acknowledge an error manually (user dismissed it)"""
try:
data = request.get_json()
if not data or 'error_key' not in data:
return jsonify({'error': 'error_key is required'}), 400
error_key = data['error_key']
health_persistence.acknowledge_error(error_key)
return jsonify({'success': True, 'message': 'Error acknowledged'})
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/active-errors', methods=['GET'])
def get_active_errors():
"""Get all active persistent errors"""
try:
category = request.args.get('category')
errors = health_persistence.get_active_errors(category)
return jsonify({'errors': errors})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -0,0 +1,75 @@
from flask import Blueprint, jsonify
import json
import os
proxmenux_bp = Blueprint('proxmenux', __name__)
# Tool descriptions mapping
TOOL_DESCRIPTIONS = {
'lvm_repair': 'LVM PV Headers Repair',
'repo_cleanup': 'Repository Cleanup',
'subscription_banner': 'Subscription Banner Removal',
'time_sync': 'Time Synchronization',
'apt_languages': 'APT Language Skip',
'journald': 'Journald Optimization',
'logrotate': 'Logrotate Optimization',
'system_limits': 'System Limits Increase',
'entropy': 'Entropy Generation (haveged)',
'memory_settings': 'Memory Settings Optimization',
'kernel_panic': 'Kernel Panic Configuration',
'apt_ipv4': 'APT IPv4 Force',
'kexec': 'kexec for quick reboots',
'network_optimization': 'Network Optimizations',
'bashrc_custom': 'Bashrc Customization',
'figurine': 'Figurine',
'fastfetch': 'Fastfetch',
'log2ram': 'Log2ram (SSD Protection)',
'amd_fixes': 'AMD CPU (Ryzen/EPYC) fixes',
'persistent_network': 'Setting persistent network interfaces'
}
@proxmenux_bp.route('/api/proxmenux/installed-tools', methods=['GET'])
def get_installed_tools():
"""Get list of installed ProxMenux tools/optimizations"""
installed_tools_path = '/usr/local/share/proxmenux/installed_tools.json'
try:
if not os.path.exists(installed_tools_path):
return jsonify({
'success': True,
'installed_tools': [],
'message': 'No ProxMenux optimizations installed yet'
})
with open(installed_tools_path, 'r') as f:
data = json.load(f)
# Convert to list format with descriptions
tools = []
for tool_key, enabled in data.items():
if enabled: # Only include enabled tools
tools.append({
'key': tool_key,
'name': TOOL_DESCRIPTIONS.get(tool_key, tool_key.replace('_', ' ').title()),
'enabled': enabled
})
# Sort alphabetically by name
tools.sort(key=lambda x: x['name'])
return jsonify({
'success': True,
'installed_tools': tools,
'total_count': len(tools)
})
except json.JSONDecodeError:
return jsonify({
'success': False,
'error': 'Invalid JSON format in installed_tools.json'
}), 500
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -34,12 +34,14 @@ from flask_health_routes import health_bp
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask_auth_routes import auth_bp from flask_auth_routes import auth_bp
from flask_proxmenux_routes import proxmenux_bp
app = Flask(__name__) app = Flask(__name__)
CORS(app) # Enable CORS for Next.js frontend CORS(app) # Enable CORS for Next.js frontend
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(health_bp) app.register_blueprint(health_bp)
app.register_blueprint(proxmenux_bp)
@@ -1836,8 +1838,15 @@ def get_interface_type(interface_name):
if '.' in interface_name: if '.' in interface_name:
return 'vlan' return 'vlan'
# Check if it's a physical interface # Check if interface has a real device symlink in /sys/class/net
if interface_name.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')): # This catches all physical interfaces including USB, regardless of naming
sys_path = f'/sys/class/net/{interface_name}/device'
if os.path.exists(sys_path):
# It's a physical interface (PCI, USB, etc.)
return 'physical'
# This handles cases where /sys might not be available
if interface_name.startswith(('enp', 'eth', 'eno', 'ens', 'enx', 'wlan', 'wlp', 'wlo', 'usb')):
return 'physical' return 'physical'
# Default to skip for unknown types # Default to skip for unknown types
@@ -2851,7 +2860,7 @@ def get_detailed_gpu_info(gpu):
clients = best_json['clients'] clients = best_json['clients']
processes = [] processes = []
for client_id, client_data in clients: for client_id, client_data in clients.items():
process_info = { process_info = {
'name': client_data.get('name', 'Unknown'), 'name': client_data.get('name', 'Unknown'),
'pid': client_data.get('pid', 'Unknown'), 'pid': client_data.get('pid', 'Unknown'),
@@ -3302,6 +3311,9 @@ def get_detailed_gpu_info(gpu):
data_retrieved = False data_retrieved = False
# CHANGE: Initialize sensors variable to None to avoid UnboundLocalError
sensors = None
# Parse temperature (Edge Temperature from sensors) # Parse temperature (Edge Temperature from sensors)
if 'sensors' in device: if 'sensors' in device:
sensors = device['sensors'] sensors = device['sensors']
@@ -3313,15 +3325,16 @@ def get_detailed_gpu_info(gpu):
pass pass
data_retrieved = True data_retrieved = True
# CHANGE: Added check to ensure sensors is not None before accessing
# Parse power draw (GFX Power or average_socket_power) # Parse power draw (GFX Power or average_socket_power)
if 'GFX Power' in sensors: if sensors and 'GFX Power' in sensors:
gfx_power = sensors['GFX Power'] gfx_power = sensors['GFX Power']
if 'value' in gfx_power: if 'value' in gfx_power:
detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W" detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W"
# print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True) # print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
pass pass
data_retrieved = True data_retrieved = True
elif 'average_socket_power' in sensors: elif sensors and 'average_socket_power' in sensors:
socket_power = sensors['average_socket_power'] socket_power = sensors['average_socket_power']
if 'value' in socket_power: if 'value' in socket_power:
detailed_info['power_draw'] = f"{socket_power['value']:.2f} W" detailed_info['power_draw'] = f"{socket_power['value']:.2f} W"
@@ -4910,7 +4923,7 @@ def api_logs():
'pid': log_entry.get('_PID', ''), 'pid': log_entry.get('_PID', ''),
'hostname': log_entry.get('_HOSTNAME', '') 'hostname': log_entry.get('_HOSTNAME', '')
}) })
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError):
continue continue
return jsonify({'logs': logs, 'total': len(logs)}) return jsonify({'logs': logs, 'total': len(logs)})
else: else:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,359 @@
"""
Health Monitor Persistence Module
Manages persistent error tracking across AppImage updates using SQLite.
Stores errors in /root/.config/proxmenux-monitor/health_monitor.db
Features:
- Persistent error storage (survives AppImage updates)
- Smart error resolution (auto-clear when VM starts, or after 48h)
- Event system for future Telegram notifications
- Manual acknowledgment support
Author: MacRimi
Version: 1.0
"""
import sqlite3
import json
import os
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
from pathlib import Path
class HealthPersistence:
"""Manages persistent health error tracking"""
# Error retention periods (seconds)
VM_ERROR_RETENTION = 48 * 3600 # 48 hours
LOG_ERROR_RETENTION = 24 * 3600 # 24 hours
DISK_ERROR_RETENTION = 48 * 3600 # 48 hours
def __init__(self):
"""Initialize persistence with database in config directory"""
self.data_dir = Path('/root/.config/proxmenux-monitor')
self.data_dir.mkdir(parents=True, exist_ok=True)
self.db_path = self.data_dir / 'health_monitor.db'
self._init_database()
def _init_database(self):
"""Initialize SQLite database with required tables"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Errors table
cursor.execute('''
CREATE TABLE IF NOT EXISTS errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
error_key TEXT UNIQUE NOT NULL,
category TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL,
details TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
resolved_at TEXT,
acknowledged INTEGER DEFAULT 0,
notification_sent INTEGER DEFAULT 0
)
''')
# Events table (for future Telegram notifications)
cursor.execute('''
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
error_key TEXT NOT NULL,
timestamp TEXT NOT NULL,
data TEXT
)
''')
# Indexes for performance
cursor.execute('CREATE INDEX IF NOT EXISTS idx_error_key ON errors(error_key)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON errors(category)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_resolved ON errors(resolved_at)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_events_error ON events(error_key)')
conn.commit()
conn.close()
def record_error(self, error_key: str, category: str, severity: str,
reason: str, details: Optional[Dict] = None) -> Dict[str, Any]:
"""
Record or update an error.
Returns event info (new_error, updated, etc.)
"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
now = datetime.now().isoformat()
details_json = json.dumps(details) if details else None
cursor.execute('''
SELECT acknowledged, resolved_at
FROM errors
WHERE error_key = ? AND acknowledged = 1
''', (error_key,))
ack_check = cursor.fetchone()
if ack_check and ack_check[1]: # Has resolved_at timestamp
try:
resolved_dt = datetime.fromisoformat(ack_check[1])
hours_since_ack = (datetime.now() - resolved_dt).total_seconds() / 3600
if hours_since_ack < 24:
# Skip re-adding recently acknowledged errors (within 24h)
conn.close()
return {'type': 'skipped_acknowledged', 'needs_notification': False}
except Exception:
pass
cursor.execute('''
SELECT id, first_seen, notification_sent, acknowledged, resolved_at
FROM errors WHERE error_key = ?
''', (error_key,))
existing = cursor.fetchone()
event_info = {'type': 'updated', 'needs_notification': False}
if existing:
error_id, first_seen, notif_sent, acknowledged, resolved_at = existing
if acknowledged == 1:
conn.close()
return {'type': 'skipped_acknowledged', 'needs_notification': False}
# Update existing error (only if NOT acknowledged)
cursor.execute('''
UPDATE errors
SET last_seen = ?, severity = ?, reason = ?, details = ?
WHERE error_key = ? AND acknowledged = 0
''', (now, severity, reason, details_json, error_key))
# Check if severity escalated
cursor.execute('SELECT severity FROM errors WHERE error_key = ?', (error_key,))
old_severity_row = cursor.fetchone()
if old_severity_row:
old_severity = old_severity_row[0]
if old_severity == 'WARNING' and severity == 'CRITICAL':
event_info['type'] = 'escalated'
event_info['needs_notification'] = True
else:
# Insert new error
cursor.execute('''
INSERT INTO errors
(error_key, category, severity, reason, details, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (error_key, category, severity, reason, details_json, now, now))
event_info['type'] = 'new'
event_info['needs_notification'] = True
# Record event
self._record_event(cursor, event_info['type'], error_key,
{'severity': severity, 'reason': reason})
conn.commit()
conn.close()
return event_info
def resolve_error(self, error_key: str, reason: str = 'auto-resolved'):
"""Mark an error as resolved"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute('''
UPDATE errors
SET resolved_at = ?
WHERE error_key = ? AND resolved_at IS NULL
''', (now, error_key))
if cursor.rowcount > 0:
self._record_event(cursor, 'resolved', error_key, {'reason': reason})
conn.commit()
conn.close()
def acknowledge_error(self, error_key: str):
"""
Manually acknowledge an error (won't notify again or re-appear for 24h).
Also marks as resolved so it disappears from active errors.
"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute('''
UPDATE errors
SET acknowledged = 1, resolved_at = ?
WHERE error_key = ?
''', (now, error_key))
self._record_event(cursor, 'acknowledged', error_key, {})
conn.commit()
conn.close()
def get_active_errors(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all active (unresolved) errors, optionally filtered by category"""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if category:
cursor.execute('''
SELECT * FROM errors
WHERE resolved_at IS NULL AND category = ?
ORDER BY severity DESC, last_seen DESC
''', (category,))
else:
cursor.execute('''
SELECT * FROM errors
WHERE resolved_at IS NULL
ORDER BY severity DESC, last_seen DESC
''')
rows = cursor.fetchall()
conn.close()
errors = []
for row in rows:
error_dict = dict(row)
if error_dict.get('details'):
error_dict['details'] = json.loads(error_dict['details'])
errors.append(error_dict)
return errors
def cleanup_old_errors(self):
"""Clean up old resolved errors and auto-resolve stale errors"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
now = datetime.now()
# Delete resolved errors older than 7 days
cutoff_resolved = (now - timedelta(days=7)).isoformat()
cursor.execute('DELETE FROM errors WHERE resolved_at < ?', (cutoff_resolved,))
# Auto-resolve VM/CT errors older than 48h
cutoff_vm = (now - timedelta(seconds=self.VM_ERROR_RETENTION)).isoformat()
cursor.execute('''
UPDATE errors
SET resolved_at = ?
WHERE category = 'vms'
AND resolved_at IS NULL
AND first_seen < ?
AND acknowledged = 0
''', (now.isoformat(), cutoff_vm))
# Auto-resolve log errors older than 24h
cutoff_logs = (now - timedelta(seconds=self.LOG_ERROR_RETENTION)).isoformat()
cursor.execute('''
UPDATE errors
SET resolved_at = ?
WHERE category = 'logs'
AND resolved_at IS NULL
AND first_seen < ?
AND acknowledged = 0
''', (now.isoformat(), cutoff_logs))
# Delete old events (>30 days)
cutoff_events = (now - timedelta(days=30)).isoformat()
cursor.execute('DELETE FROM events WHERE timestamp < ?', (cutoff_events,))
conn.commit()
conn.close()
def check_vm_running(self, vm_id: str) -> bool:
"""
Check if a VM/CT is running and resolve error if so.
Returns True if running and error was resolved.
"""
import subprocess
try:
# Check qm status for VMs
result = subprocess.run(
['qm', 'status', vm_id],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and 'running' in result.stdout.lower():
self.resolve_error(f'vm_{vm_id}', 'VM started')
return True
# Check pct status for containers
result = subprocess.run(
['pct', 'status', vm_id],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and 'running' in result.stdout.lower():
self.resolve_error(f'ct_{vm_id}', 'Container started')
return True
return False
except Exception:
return False
def _record_event(self, cursor, event_type: str, error_key: str, data: Dict):
"""Internal: Record an event"""
cursor.execute('''
INSERT INTO events (event_type, error_key, timestamp, data)
VALUES (?, ?, ?, ?)
''', (event_type, error_key, datetime.now().isoformat(), json.dumps(data)))
def get_unnotified_errors(self) -> List[Dict[str, Any]]:
"""Get errors that need Telegram notification"""
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM errors
WHERE notification_sent = 0
AND resolved_at IS NULL
AND acknowledged = 0
ORDER BY severity DESC, first_seen ASC
''')
rows = cursor.fetchall()
conn.close()
errors = []
for row in rows:
error_dict = dict(row)
if error_dict.get('details'):
error_dict['details'] = json.loads(error_dict['details'])
errors.append(error_dict)
return errors
def mark_notified(self, error_key: str):
"""Mark error as notified"""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
cursor.execute('''
UPDATE errors
SET notification_sent = 1
WHERE error_key = ?
''', (error_key,))
conn.commit()
conn.close()
# Global instance
health_persistence = HealthPersistence()

Binary file not shown.