mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-18 03:26:17 +00:00
Merge branch 'MacRimi:main' into main
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
23
AppImage/hooks/use-mobile.tsx
Normal file
23
AppImage/hooks/use-mobile.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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 },
|
|
||||||
]
|
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
75
AppImage/scripts/flask_proxmenux_routes.py
Normal file
75
AppImage/scripts/flask_proxmenux_routes.py
Normal 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
|
||||||
@@ -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
359
AppImage/scripts/health_persistence.py
Normal file
359
AppImage/scripts/health_persistence.py
Normal 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()
|
||||||
BIN
scripts/test/ProxMenux-1.0.1-beat1.AppImage
Executable file
BIN
scripts/test/ProxMenux-1.0.1-beat1.AppImage
Executable file
Binary file not shown.
Reference in New Issue
Block a user