Merge branch 'MacRimi:main' into main

This commit is contained in:
cod378
2025-11-05 23:24:21 -03:00
committed by GitHub
27 changed files with 3443 additions and 176 deletions

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,29 @@
---
name: Bug Report
about: Reporta un problema en el proyecto
title: "[BUG] Describe el problema"
labels: bug
assignees: 'MacRimi'
---
## Descripción
Describe el error de forma clara y concisa.
## Pasos para reproducir
1. ...
2. ...
3. ...
## Comportamiento esperado
¿Qué debería ocurrir?
## Capturas de pantalla (Obligatorio)
Agrega imágenes para ayudar a entender el problema.
## Entorno
- Sistema operativo:
- Versión del software:
- Otros detalles relevantes:
## Información adicional
Agrega cualquier otro contexto sobre el problema aquí.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Soporte General
url: https://github.com/MacRimi/ProxMenux/discussions
about: Si tu solicitud no es un bug ni un feature, usa las discusiones.

View File

@@ -0,0 +1,19 @@
---
name: Feature Request
about: Sugiere una nueva funcionalidad o mejora
title: "[FEATURE] Describe la propuesta"
labels: enhancement
assignees: 'MacRimi'
---
## Descripción
Explica la funcionalidad que propones.
## Motivación
¿Por qué es importante esta mejora? ¿Qué problema resuelve?
## Alternativas consideradas
¿Hay otras soluciones que hayas pensado?
## Información adicional
Agrega cualquier detalle extra que ayude a entender la propuesta.

View File

@@ -0,0 +1,243 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent } from "./ui/dialog"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Shield, Lock, User, AlertCircle } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
interface AuthSetupProps {
onComplete: () => void
}
export function AuthSetup({ onComplete }: AuthSetupProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState<"choice" | "setup">("choice")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
const checkOnboardingStatus = async () => {
try {
const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json()
console.log("[v0] Auth status for modal check:", data)
// Show modal if auth is not configured and not declined
if (!data.auth_configured) {
setTimeout(() => setOpen(true), 500)
}
} catch (error) {
console.error("[v0] Failed to check auth status:", error)
// Fail-safe: show modal if we can't check status
setTimeout(() => setOpen(true), 500)
}
}
checkOnboardingStatus()
}, [])
const handleSkipAuth = async () => {
setLoading(true)
setError("")
try {
console.log("[v0] Skipping authentication setup...")
const response = await fetch(getApiUrl("/api/auth/skip"), {
method: "POST",
headers: { "Content-Type": "application/json" },
})
const data = await response.json()
console.log("[v0] Auth skip response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication")
}
console.log("[v0] Authentication skipped successfully")
localStorage.setItem("proxmenux-auth-declined", "true")
setOpen(false)
onComplete()
} catch (err) {
console.error("[v0] Auth skip error:", err)
setError(err instanceof Error ? err.message : "Failed to save preference")
} finally {
setLoading(false)
}
}
const handleSetupAuth = async () => {
setError("")
if (!username || !password) {
setError("Please fill in all fields")
return
}
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
console.log("[v0] Setting up authentication...")
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
password,
}),
})
const data = await response.json()
console.log("[v0] Auth setup response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication")
}
if (data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
localStorage.removeItem("proxmenux-auth-declined")
console.log("[v0] Authentication setup successful")
}
setOpen(false)
onComplete()
} catch (err) {
console.error("[v0] Auth setup error:", err)
setError(err instanceof Error ? err.message : "Failed to setup authentication")
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
{step === "choice" ? (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
<p className="text-muted-foreground">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
</p>
</div>
<div className="space-y-3">
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
<Lock className="h-4 w-4 mr-2" />
Yes, Setup Password
</Button>
<Button
onClick={handleSkipAuth}
variant="outline"
className="w-full bg-transparent"
size="lg"
disabled={loading}
>
No, Continue Without Protection
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
</div>
) : (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
<Lock className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-2xl font-bold">Setup Authentication</h2>
<p className="text-muted-foreground">Create a username and password to protect your dashboard</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="username"
type="text"
placeholder="Enter username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Setting up..." : "Setup Authentication"}
</Button>
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
Back
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -64,9 +64,12 @@ const formatMemory = (memoryKB: number | string): string => {
return `${tb.toFixed(1)} TB` return `${tb.toFixed(1)} TB`
} }
// Convert to GB if >= 1024 MB
if (mb >= 1024) { if (mb >= 1024) {
const gb = mb / 1024 const gb = mb / 1024
// If GB value is greater than 999, convert to TB
if (gb > 999) {
return `${(gb / 1024).toFixed(2)} TB`
}
return `${gb.toFixed(1)} GB` return `${gb.toFixed(1)} GB`
} }
@@ -168,6 +171,22 @@ export default function Hardware() {
refreshInterval: 5000, refreshInterval: 5000,
}) })
useEffect(() => {
if (hardwareData?.storage_devices) {
console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)
hardwareData.storage_devices.forEach((device) => {
if (device.name.startsWith("nvme")) {
console.log(`[v0] NVMe device ${device.name}:`, {
pcie_gen: device.pcie_gen,
pcie_width: device.pcie_width,
pcie_max_gen: device.pcie_max_gen,
pcie_max_width: device.pcie_max_width,
})
}
})
}
}, [hardwareData])
const [selectedGPU, setSelectedGPU] = useState<GPU | null>(null) const [selectedGPU, setSelectedGPU] = useState<GPU | null>(null)
const [realtimeGPUData, setRealtimeGPUData] = useState<any>(null) const [realtimeGPUData, setRealtimeGPUData] = useState<any>(null)
const [detailsLoading, setDetailsLoading] = useState(false) const [detailsLoading, setDetailsLoading] = useState(false)
@@ -1658,6 +1677,61 @@ export default function Hardware() {
const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate) const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate)
const getLinkSpeedInfo = (device: StorageDevice) => {
// NVMe PCIe information
if (device.name.startsWith("nvme") && (device.pcie_gen || device.pcie_width)) {
const current = `${device.pcie_gen || ""} ${device.pcie_width || ""}`.trim()
const max =
device.pcie_max_gen && device.pcie_max_width
? `${device.pcie_max_gen} ${device.pcie_max_width}`.trim()
: null
const isLowerSpeed = max && current !== max
return {
text: current || null,
maxText: max,
isWarning: isLowerSpeed,
color: isLowerSpeed ? "text-orange-500" : "text-blue-500",
}
}
// SATA information
if (device.sata_version) {
return {
text: device.sata_version,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
// SAS information
if (device.sas_version || device.sas_speed) {
const text = [device.sas_version, device.sas_speed].filter(Boolean).join(" ")
return {
text: text || null,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
// Generic link speed
if (device.link_speed) {
return {
text: device.link_speed,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
return null
}
const linkSpeed = getLinkSpeedInfo(device)
return ( return (
<div <div
key={index} key={index}
@@ -1672,6 +1746,14 @@ export default function Hardware() {
{device.model && ( {device.model && (
<p className="text-xs text-muted-foreground line-clamp-2 break-words">{device.model}</p> <p className="text-xs text-muted-foreground line-clamp-2 break-words">{device.model}</p>
)} )}
{linkSpeed && (
<div className="mt-1 flex items-center gap-1">
<span className={`text-xs font-medium ${linkSpeed.color}`}>{linkSpeed.text}</span>
{linkSpeed.maxText && linkSpeed.isWarning && (
<span className="text-xs font-medium text-blue-500">(max: {linkSpeed.maxText})</span>
)}
</div>
)}
</div> </div>
) )
})} })}
@@ -1695,46 +1777,44 @@ export default function Hardware() {
<span className="font-mono text-sm">{selectedDisk.name}</span> <span className="font-mono text-sm">{selectedDisk.name}</span>
</div> </div>
{selectedDisk.name && ( <div className="flex justify-between border-b border-border/50 pb-2">
<div className="flex justify-between border-b border-border/50 pb-2"> <span className="text-sm font-medium text-muted-foreground">Type</span>
<span className="text-sm font-medium text-muted-foreground">Type</span> {(() => {
{(() => { const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { let diskType = "HDD"
let diskType = "HDD"
if (diskName.startsWith("nvme")) { if (diskName.startsWith("nvme")) {
diskType = "NVMe" diskType = "NVMe"
} else if (rotationRate !== undefined && rotationRate !== null) { } else if (rotationRate !== undefined && rotationRate !== null) {
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
if (rateNum === 0 || isNaN(rateNum)) { if (rateNum === 0 || isNaN(rateNum)) {
diskType = "SSD"
}
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
diskType = "SSD" diskType = "SSD"
} }
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
const badgeStyles: Record<string, { className: string; label: string }> = { diskType = "SSD"
NVMe: {
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
label: "NVMe SSD",
},
SSD: {
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
label: "SSD",
},
HDD: {
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
label: "HDD",
},
}
return badgeStyles[diskType]
} }
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate) const badgeStyles: Record<string, { className: string; label: string }> = {
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge> NVMe: {
})()} className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
</div> label: "NVMe SSD",
)} },
SSD: {
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
label: "SSD",
},
HDD: {
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
label: "HDD",
},
}
return badgeStyles[diskType]
}
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
})()}
</div>
{selectedDisk.size && ( {selectedDisk.size && (
<div className="flex justify-between border-b border-border/50 pb-2"> <div className="flex justify-between border-b border-border/50 pb-2">
@@ -1743,6 +1823,84 @@ export default function Hardware() {
</div> </div>
)} )}
<div className="pt-2">
<h3 className="text-sm font-semibold text-muted-foreground mb-2 uppercase tracking-wide">
Interface Information
</h3>
</div>
{/* NVMe PCIe Information */}
{selectedDisk.name.startsWith("nvme") && (
<>
{selectedDisk.pcie_gen || selectedDisk.pcie_width ? (
<>
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Current Link Speed</span>
<span
className={`text-sm font-medium ${
selectedDisk.pcie_max_gen &&
selectedDisk.pcie_max_width &&
`${selectedDisk.pcie_gen} ${selectedDisk.pcie_width}` !==
`${selectedDisk.pcie_max_gen} ${selectedDisk.pcie_max_width}`
? "text-orange-500"
: "text-blue-500"
}`}
>
{selectedDisk.pcie_gen || "PCIe"} {selectedDisk.pcie_width || ""}
</span>
</div>
{selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Maximum Link Speed</span>
<span className="text-sm font-medium text-blue-500">
{selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width}
</span>
</div>
)}
</>
) : (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">PCIe Link Speed</span>
<span className="text-sm text-muted-foreground italic">Detecting...</span>
</div>
)}
</>
)}
{/* SATA Information */}
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">SATA Version</span>
<span className="text-sm font-medium text-blue-500">{selectedDisk.sata_version}</span>
</div>
)}
{/* SAS Information */}
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">SAS Version</span>
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_version}</span>
</div>
)}
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">SAS Speed</span>
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_speed}</span>
</div>
)}
{/* Generic Link Speed - only show if no specific interface info */}
{!selectedDisk.name.startsWith("nvme") &&
selectedDisk.link_speed &&
!selectedDisk.pcie_gen &&
!selectedDisk.sata_version &&
!selectedDisk.sas_version && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Link Speed</span>
<span className="text-sm font-medium text-blue-500">{selectedDisk.link_speed}</span>
</div>
)}
{selectedDisk.model && ( {selectedDisk.model && (
<div className="flex justify-between border-b border-border/50 pb-2"> <div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Model</span> <span className="text-sm font-medium text-muted-foreground">Model</span>
@@ -1806,13 +1964,6 @@ export default function Hardware() {
<span className="text-sm">{selectedDisk.form_factor}</span> <span className="text-sm">{selectedDisk.form_factor}</span>
</div> </div>
)} )}
{selectedDisk.sata_version && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">SATA Version</span>
<span className="text-sm">{selectedDisk.sata_version}</span>
</div>
)}
</div> </div>
)} )}
</DialogContent> </DialogContent>

View File

@@ -0,0 +1,269 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Loader2, CheckCircle2, AlertTriangle, XCircle, Activity } from "lucide-react"
interface HealthDetail {
status: string
reason?: string
[key: string]: any
}
interface HealthDetails {
overall: string
summary: string
details: {
[category: string]: HealthDetail | { [key: string]: HealthDetail }
}
timestamp: string
}
interface HealthStatusModalProps {
open: boolean
onOpenChange: (open: boolean) => void
getApiUrl: (path: string) => string
}
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
fetchHealthDetails()
}
}, [open])
const fetchHealthDetails = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(getApiUrl("/api/health/details"))
if (!response.ok) {
throw new Error("Failed to fetch health details")
}
const data = await response.json()
console.log("[v0] Health data received:", data)
setHealthData(data)
} catch (err) {
console.error("[v0] Error fetching health data:", err)
setError(err instanceof Error ? err.message : "Unknown error")
} finally {
setLoading(false)
}
}
const getHealthStats = () => {
if (!healthData?.details) {
return { total: 0, healthy: 0, warnings: 0, critical: 0 }
}
let healthy = 0
let warnings = 0
let critical = 0
let total = 0
const countStatus = (detail: any) => {
if (detail && typeof detail === "object" && detail.status) {
total++
const status = detail.status.toUpperCase()
if (status === "OK") healthy++
else if (status === "WARNING") warnings++
else if (status === "CRITICAL") critical++
}
}
Object.values(healthData.details).forEach((categoryData) => {
if (categoryData && typeof categoryData === "object") {
if ("status" in categoryData) {
countStatus(categoryData)
} else {
Object.values(categoryData).forEach(countStatus)
}
}
})
return { total, healthy, warnings, critical }
}
const getGroupedChecks = () => {
if (!healthData?.details) return {}
const grouped: { [key: string]: Array<{ name: string; status: string; reason?: string; details?: any }> } = {}
Object.entries(healthData.details).forEach(([category, categoryData]) => {
if (!categoryData || typeof categoryData !== "object") return
const categoryName = category.charAt(0).toUpperCase() + category.slice(1)
grouped[categoryName] = []
if ("status" in categoryData) {
grouped[categoryName].push({
name: categoryName,
status: categoryData.status,
reason: categoryData.reason,
details: categoryData,
})
} else {
Object.entries(categoryData).forEach(([subKey, subData]: [string, any]) => {
if (subData && typeof subData === "object" && "status" in subData) {
grouped[categoryName].push({
name: subKey,
status: subData.status,
reason: subData.reason,
details: subData,
})
}
})
}
})
return grouped
}
const getStatusIcon = (status: string) => {
const statusUpper = status?.toUpperCase()
switch (statusUpper) {
case "OK":
return <CheckCircle2 className="h-5 w-5 text-green-500" />
case "WARNING":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
case "CRITICAL":
return <XCircle className="h-5 w-5 text-red-500" />
default:
return <Activity className="h-5 w-5 text-gray-500" />
}
}
const getStatusBadge = (status: string) => {
const statusUpper = status?.toUpperCase()
switch (statusUpper) {
case "OK":
return <Badge className="bg-green-500">Healthy</Badge>
case "WARNING":
return <Badge className="bg-yellow-500">Warning</Badge>
case "CRITICAL":
return <Badge className="bg-red-500">Critical</Badge>
default:
return <Badge>Unknown</Badge>
}
}
const stats = getHealthStats()
const groupedChecks = getGroupedChecks()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="h-6 w-6" />
System Health Status
</DialogTitle>
<DialogDescription>Detailed health checks for all system components</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
<p className="font-medium">Error loading health status</p>
<p className="text-sm">{error}</p>
</div>
)}
{healthData && !loading && (
<div className="space-y-6">
{/* Overall Status Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Overall Status</span>
{getStatusBadge(healthData.overall)}
</CardTitle>
</CardHeader>
<CardContent>
{healthData.summary && <p className="text-sm text-muted-foreground mb-4">{healthData.summary}</p>}
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<div className="text-2xl font-bold">{stats.total}</div>
<div className="text-sm text-muted-foreground">Total Checks</div>
</div>
<div>
<div className="text-2xl font-bold text-green-500">{stats.healthy}</div>
<div className="text-sm text-muted-foreground">Healthy</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 */}
{Object.entries(groupedChecks).map(([category, checks]) => (
<Card key={category}>
<CardHeader>
<CardTitle className="text-lg">{category}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{checks.map((check, index) => (
<div
key={`${category}-${index}`}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-muted/50 transition-colors"
>
<div className="mt-0.5">{getStatusIcon(check.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">{check.name}</p>
<Badge variant="outline" className="shrink-0">
{check.status}
</Badge>
</div>
{check.reason && <p className="text-sm text-muted-foreground mt-1">{check.reason}</p>}
{check.details && (
<div className="text-xs text-muted-foreground mt-2 space-y-0.5">
{Object.entries(check.details).map(([key, value]) => {
if (key === "status" || key === "reason" || typeof value === "object") return null
return (
<div key={key} className="font-mono">
{key}: {String(value)}
</div>
)
})}
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
{healthData.timestamp && (
<div className="text-xs text-muted-foreground text-center">
Last updated: {new Date(healthData.timestamp).toLocaleString()}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,141 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Lock, User, AlertCircle, Server } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
interface LoginProps {
onLogin: () => void
}
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!username || !password) {
setError("Please enter username and password")
return
}
setLoading(true)
try {
const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Login failed")
}
// Save token
localStorage.setItem("proxmenux-auth-token", data.token)
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="w-20 h-20 relative flex items-center justify-center bg-primary/10 rounded-lg">
<Image
src="/images/proxmenux-logo.png"
alt="ProxMenux Logo"
width={80}
height={80}
className="object-contain"
priority
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.parentElement?.querySelector(".fallback-icon")
if (fallback) {
fallback.classList.remove("hidden")
}
}}
/>
<Server className="h-12 w-12 text-primary absolute fallback-icon hidden" />
</div>
</div>
<div>
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
</div>
</div>
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="login-username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="login-password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
disabled={loading}
autoComplete="current-password"
/>
</div>
</div>
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.1</p>
</div>
</div>
)
}

View File

@@ -17,8 +17,13 @@ import {
Cpu, Cpu,
FileText, FileText,
Rocket, Rocket,
Zap,
Shield,
Link2,
Gauge,
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { Checkbox } from "./ui/checkbox"
interface OnboardingSlide { interface OnboardingSlide {
id: number id: number
@@ -27,6 +32,7 @@ interface OnboardingSlide {
image?: string image?: string
icon: React.ReactNode icon: React.ReactNode
gradient: string gradient: string
features?: { icon: React.ReactNode; text: string }[]
} }
const slides: OnboardingSlide[] = [ const slides: OnboardingSlide[] = [
@@ -40,6 +46,35 @@ const slides: OnboardingSlide[] = [
}, },
{ {
id: 1, id: 1,
title: "What's New in This Version",
description: "We've added exciting new features and improvements to make ProxMenux Monitor even better!",
icon: <Zap className="h-16 w-16" />,
gradient: "from-amber-500 via-orange-500 to-red-500",
features: [
{
icon: <Link2 className="h-5 w-5" />,
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
},
{
icon: <Shield className="h-5 w-5" />,
text: "Authentication System - Secure your dashboard with password protection",
},
{
icon: <Gauge className="h-5 w-5" />,
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
},
{
icon: <HardDrive className="h-5 w-5" />,
text: "Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
},
{
icon: <Network className="h-5 w-5" />,
text: "SATA/SAS Information - View detailed interface information for all storage devices",
},
],
},
{
id: 2,
title: "System Overview", title: "System Overview",
description: description:
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.", "Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
@@ -48,7 +83,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-cyan-500", gradient: "from-blue-500 to-cyan-500",
}, },
{ {
id: 2, id: 3,
title: "Storage Management", title: "Storage Management",
description: description:
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.", "Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
@@ -57,7 +92,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-cyan-500 to-teal-500", gradient: "from-cyan-500 to-teal-500",
}, },
{ {
id: 3, id: 4,
title: "Network Metrics", title: "Network Metrics",
description: description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.", "Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
@@ -66,7 +101,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-teal-500 to-green-500", gradient: "from-teal-500 to-green-500",
}, },
{ {
id: 4, id: 5,
title: "Virtual Machines & Containers", title: "Virtual Machines & Containers",
description: description:
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.", "Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
@@ -75,7 +110,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-green-500 to-emerald-500", gradient: "from-green-500 to-emerald-500",
}, },
{ {
id: 5, id: 6,
title: "Hardware Information", title: "Hardware Information",
description: description:
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.", "Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
@@ -84,7 +119,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-emerald-500 to-blue-500", gradient: "from-emerald-500 to-blue-500",
}, },
{ {
id: 6, id: 7,
title: "System Logs", title: "System Logs",
description: description:
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.", "Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
@@ -93,7 +128,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-indigo-500", gradient: "from-blue-500 to-indigo-500",
}, },
{ {
id: 7, id: 8,
title: "Ready for the Future!", title: "Ready for the Future!",
description: description:
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.", "ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
@@ -106,6 +141,7 @@ export function OnboardingCarousel() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0) const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next") const [direction, setDirection] = useState<"next" | "prev">("next")
const [dontShowAgain, setDontShowAgain] = useState(false)
useEffect(() => { useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen") const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
@@ -119,6 +155,9 @@ export function OnboardingCarousel() {
setDirection("next") setDirection("next")
setCurrentSlide(currentSlide + 1) setCurrentSlide(currentSlide + 1)
} else { } else {
if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false) setOpen(false)
} }
} }
@@ -131,11 +170,16 @@ export function OnboardingCarousel() {
} }
const handleSkip = () => { const handleSkip = () => {
if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false) setOpen(false)
} }
const handleDontShowAgain = () => { const handleClose = () => {
localStorage.setItem("proxmenux-onboarding-seen", "true") if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false) setOpen(false)
} }
@@ -147,7 +191,7 @@ export function OnboardingCarousel() {
const slide = slides[currentSlide] const slide = slides[currentSlide]
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent"> <DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl"> <div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
{/* Close button */} {/* Close button */}
@@ -155,7 +199,7 @@ export function OnboardingCarousel() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background" className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={handleSkip} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -197,14 +241,28 @@ export function OnboardingCarousel() {
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" /> <div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
</div> </div>
<div className="p-4 md:p-8 space-y-4 md:space-y-6"> <div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
<div className="space-y-2 md:space-y-3"> <div className="space-y-2 md:space-y-3">
<h2 className="text-2xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2> <h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
<p className="text-base md:text-lg text-muted-foreground leading-relaxed text-pretty"> <p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
{slide.description} {slide.description}
</p> </p>
</div> </div>
{slide.features && (
<div className="space-y-2 md:space-y-3 py-2">
{slide.features.map((feature, index) => (
<div
key={index}
className="flex items-start gap-2 md:gap-3 p-2 md:p-3 rounded-lg bg-muted/50 border border-border/50"
>
<div className="text-blue-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
</div>
))}
</div>
)}
{/* Progress dots */} {/* Progress dots */}
<div className="flex items-center justify-center gap-2 py-2 md:py-4"> <div className="flex items-center justify-center gap-2 py-2 md:py-4">
{slides.map((_, index) => ( {slides.map((_, index) => (
@@ -221,12 +279,12 @@ export function OnboardingCarousel() {
))} ))}
</div> </div>
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 md:gap-4"> <div className="flex flex-col sm:flex-row items-center justify-between gap-2 md:gap-4">
<Button <Button
variant="ghost" variant="ghost"
onClick={handlePrev} onClick={handlePrev}
disabled={currentSlide === 0} disabled={currentSlide === 0}
className="gap-2 w-full sm:w-auto" className="gap-2 w-full sm:w-auto text-sm"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
Previous Previous
@@ -235,10 +293,17 @@ export function OnboardingCarousel() {
<div className="flex gap-2 w-full sm:w-auto"> <div className="flex gap-2 w-full sm:w-auto">
{currentSlide < slides.length - 1 ? ( {currentSlide < slides.length - 1 ? (
<> <>
<Button variant="outline" onClick={handleSkip} className="flex-1 sm:flex-none bg-transparent"> <Button
variant="outline"
onClick={handleSkip}
className="flex-1 sm:flex-none bg-transparent text-sm"
>
Skip Skip
</Button> </Button>
<Button onClick={handleNext} className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none"> <Button
onClick={handleNext}
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
>
Next Next
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
@@ -246,7 +311,7 @@ export function OnboardingCarousel() {
) : ( ) : (
<Button <Button
onClick={handleNext} onClick={handleNext}
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto" className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
> >
Get Started! Get Started!
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
@@ -255,17 +320,19 @@ export function OnboardingCarousel() {
</div> </div>
</div> </div>
{/* Don't show again */} <div className="flex items-center justify-center gap-2 pt-2 pb-1">
{currentSlide === slides.length - 1 && ( <Checkbox
<div className="text-center pt-2"> id="dont-show-again"
<button checked={dontShowAgain}
onClick={handleDontShowAgain} onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline" />
> <label
Don't show again htmlFor="dont-show-again"
</button> className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
</div> >
)} Don't show this again
</label>
</div>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware" import Hardware from "./hardware"
import { SystemLogs } from "./system-logs" import { SystemLogs } from "./system-logs"
import { OnboardingCarousel } from "./onboarding-carousel" import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
import { import {
RefreshCw, RefreshCw,
@@ -63,6 +64,7 @@ export function ProxmoxDashboard() {
const [activeTab, setActiveTab] = useState("overview") const [activeTab, setActiveTab] = useState("overview")
const [showNavigation, setShowNavigation] = useState(true) const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0) const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
const fetchSystemData = useCallback(async () => { const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...") console.log("[v0] Fetching system data from Flask server...")
@@ -244,7 +246,10 @@ export function ProxmoxDashboard() {
</div> </div>
)} )}
<header className="border-b border-border bg-card sticky top-0 z-50 shadow-sm"> <header
className="border-b border-border bg-card sticky top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
onClick={() => setShowHealthModal(true)}
>
<div className="container mx-auto px-4 md:px-6 py-4 md:py-4"> <div className="container mx-auto px-4 md:px-6 py-4 md:py-4">
{/* Logo and Title */} {/* Logo and Title */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -299,7 +304,10 @@ export function ProxmoxDashboard() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={refreshData} onClick={(e) => {
e.stopPropagation()
refreshData()
}}
disabled={isRefreshing} disabled={isRefreshing}
className="border-border/50 bg-transparent hover:bg-secondary" className="border-border/50 bg-transparent hover:bg-secondary"
> >
@@ -307,7 +315,9 @@ export function ProxmoxDashboard() {
Refresh Refresh
</Button> </Button>
<ThemeToggle /> <div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
</div> </div>
{/* Mobile Actions */} {/* Mobile Actions */}
@@ -317,11 +327,22 @@ export function ProxmoxDashboard() {
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span> <span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
</Badge> </Badge>
<Button variant="ghost" size="sm" onClick={refreshData} disabled={isRefreshing} className="h-8 w-8 p-0"> <Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
refreshData()
}}
disabled={isRefreshing}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button> </Button>
<ThemeToggle /> <div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
</div> </div>
</div> </div>
@@ -534,6 +555,8 @@ export function ProxmoxDashboard() {
</p> </p>
</footer> </footer>
</div> </div>
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
</div> </div>
) )
} }

View File

@@ -0,0 +1,434 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Shield, Lock, User, AlertCircle, CheckCircle, Info } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// Setup form state
const [showSetupForm, setShowSetupForm] = useState(false)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
// Change password form state
const [showChangePassword, setShowChangePassword] = useState(false)
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("")
useEffect(() => {
checkAuthStatus()
}, [])
const checkAuthStatus = async () => {
try {
const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json()
setAuthEnabled(data.auth_enabled || false)
} catch (err) {
console.error("Failed to check auth status:", err)
}
}
const handleEnableAuth = async () => {
setError("")
setSuccess("")
if (!username || !password) {
setError("Please fill in all fields")
return
}
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
password,
enable_auth: true,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to enable authentication")
}
// Save token
localStorage.setItem("proxmenux-auth-token", data.token)
localStorage.setItem("proxmenux-auth-setup-complete", "true")
setSuccess("Authentication enabled successfully!")
setAuthEnabled(true)
setShowSetupForm(false)
setUsername("")
setPassword("")
setConfirmPassword("")
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to enable authentication")
} finally {
setLoading(false)
}
}
const handleDisableAuth = async () => {
if (
!confirm(
"Are you sure you want to disable authentication? This will remove password protection from your dashboard.",
)
) {
return
}
setLoading(true)
setError("")
setSuccess("")
try {
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_auth: false }),
})
if (!response.ok) throw new Error("Failed to disable authentication")
localStorage.removeItem("proxmenux-auth-token")
setSuccess("Authentication disabled successfully!")
setAuthEnabled(false)
} catch (err) {
setError("Failed to disable authentication. Please try again.")
} finally {
setLoading(false)
}
}
const handleChangePassword = async () => {
setError("")
setSuccess("")
if (!currentPassword || !newPassword) {
setError("Please fill in all fields")
return
}
if (newPassword !== confirmNewPassword) {
setError("New passwords do not match")
return
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const response = await fetch(getApiUrl("/api/auth/change-password"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to change password")
}
// Update token if provided
if (data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
}
setSuccess("Password changed successfully!")
setShowChangePassword(false)
setCurrentPassword("")
setNewPassword("")
setConfirmNewPassword("")
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to change password")
} finally {
setLoading(false)
}
}
const handleLogout = () => {
localStorage.removeItem("proxmenux-auth-token")
window.location.reload()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground mt-2">Manage your dashboard security and preferences</p>
</div>
{/* Authentication Settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<CardTitle>Authentication</CardTitle>
</div>
<CardDescription>Protect your dashboard with username and password authentication</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-500">{error}</p>
</div>
)}
{success && (
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-500">{success}</p>
</div>
)}
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${authEnabled ? "bg-green-500/10" : "bg-gray-500/10"}`}
>
<Lock className={`h-5 w-5 ${authEnabled ? "text-green-500" : "text-gray-500"}`} />
</div>
<div>
<p className="font-medium">Authentication Status</p>
<p className="text-sm text-muted-foreground">
{authEnabled ? "Password protection is enabled" : "No password protection"}
</p>
</div>
</div>
<div
className={`px-3 py-1 rounded-full text-sm font-medium ${authEnabled ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"}`}
>
{authEnabled ? "Enabled" : "Disabled"}
</div>
</div>
{!authEnabled && !showSetupForm && (
<div className="space-y-3">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-500">
Enable authentication to protect your dashboard when accessing from non-private networks.
</p>
</div>
<Button onClick={() => setShowSetupForm(true)} className="w-full bg-blue-500 hover:bg-blue-600">
<Shield className="h-4 w-4 mr-2" />
Enable Authentication
</Button>
</div>
)}
{!authEnabled && showSetupForm && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Setup Authentication</h3>
<div className="space-y-2">
<Label htmlFor="setup-username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-username"
type="text"
placeholder="Enter username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="setup-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-password"
type="password"
placeholder="Enter password (min 6 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="setup-confirm-password">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="setup-confirm-password"
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleEnableAuth} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Enabling..." : "Enable"}
</Button>
<Button onClick={() => setShowSetupForm(false)} variant="outline" className="flex-1" disabled={loading}>
Cancel
</Button>
</div>
</div>
)}
{authEnabled && (
<div className="space-y-3">
<Button onClick={handleLogout} variant="outline" className="w-full bg-transparent">
Logout
</Button>
{!showChangePassword && (
<Button onClick={() => setShowChangePassword(true)} variant="outline" className="w-full">
<Lock className="h-4 w-4 mr-2" />
Change Password
</Button>
)}
{showChangePassword && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Change Password</h3>
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="current-password"
type="password"
placeholder="Enter current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="new-password"
type="password"
placeholder="Enter new password (min 6 characters)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-new-password">Confirm New Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-new-password"
type="password"
placeholder="Confirm new password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleChangePassword}
className="flex-1 bg-blue-500 hover:bg-blue-600"
disabled={loading}
>
{loading ? "Changing..." : "Change Password"}
</Button>
<Button
onClick={() => setShowChangePassword(false)}
variant="outline"
className="flex-1"
disabled={loading}
>
Cancel
</Button>
</div>
</div>
)}
<Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
Disable Authentication
</Button>
</div>
)}
</CardContent>
</Card>
{/* About Section */}
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
<CardDescription>ProxMenux Monitor information</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Version</span>
<span className="font-medium">1.0.1</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Build</span>
<span className="font-medium">Debian Package</span>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,10 +1,13 @@
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText } from "path-to-icons" import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } from "lucide-react"
const menuItems = [ const menuItems = [
{ name: "Overview", href: "/", icon: LayoutDashboard }, { name: "Overview", href: "/", icon: LayoutDashboard },
{ name: "Storage", href: "/storage", icon: HardDrive }, { name: "Storage", href: "/storage", icon: HardDrive },
{ name: "Network", href: "/network", icon: Network }, { name: "Network", href: "/network", icon: Network },
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server }, { name: "Virtual Machines", href: "/virtual-machines", icon: Server },
{ name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section { name: "Hardware", href: "/hardware", icon: Cpu },
{ name: "System Logs", href: "/logs", icon: FileText }, { name: "System Logs", href: "/logs", icon: FileText },
{ name: "Settings", href: "/settings", icon: SettingsIcon },
] ]
// ... existing code ...

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress" import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react" import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
import { formatStorage } from "@/lib/utils"
interface StorageData { interface StorageData {
total: number total: number
@@ -116,10 +117,10 @@ export function StorageMetrics() {
<HardDrive className="h-4 w-4 text-muted-foreground" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.total.toFixed(1)} GB</div> <div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.total)}</div>
<Progress value={usagePercent} className="mt-2" /> <Progress value={usagePercent} className="mt-2" />
<p className="text-xs text-muted-foreground mt-2"> <p className="text-xs text-muted-foreground mt-2">
{storageData.used.toFixed(1)} GB used {storageData.available.toFixed(1)} GB available {formatStorage(storageData.used)} used {formatStorage(storageData.available)} available
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -130,7 +131,7 @@ export function StorageMetrics() {
<Database className="h-4 w-4 text-muted-foreground" /> <Database className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.used.toFixed(1)} GB</div> <div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.used)}</div>
<Progress value={usagePercent} className="mt-2" /> <Progress value={usagePercent} className="mt-2" />
<p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p> <p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p>
</CardContent> </CardContent>
@@ -144,7 +145,7 @@ export function StorageMetrics() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.available.toFixed(1)} GB</div> <div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.available)}</div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20"> <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{((storageData.available / storageData.total) * 100).toFixed(1)}% Free {((storageData.available / storageData.total) * 100).toFixed(1)}% Free
@@ -201,7 +202,7 @@ export function StorageMetrics() {
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<div className="text-right"> <div className="text-right">
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
{disk.used.toFixed(1)} GB / {disk.total.toFixed(1)} GB {formatStorage(disk.used)} / {formatStorage(disk.total)}
</div> </div>
<Progress value={disk.usage_percent} className="w-24 mt-1" /> <Progress value={disk.usage_percent} className="w-24 mt-1" />
</div> </div>

View File

@@ -6,6 +6,7 @@ import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Ther
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { getApiUrl } from "../lib/api-config"
interface DiskInfo { interface DiskInfo {
name: string name: string
@@ -75,12 +76,11 @@ const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) { if (sizeInGB < 1) {
// Less than 1 GB, show in MB // Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB` return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB < 1024) { } else if (sizeInGB > 999) {
// Less than 1024 GB, show in GB return `${(sizeInGB / 1024).toFixed(2)} TB`
return `${sizeInGB.toFixed(1)} GB`
} else { } else {
// 1024 GB or more, show in TB // Between 1 and 999 GB, show in GB
return `${(sizeInGB / 1024).toFixed(1)} TB` return `${sizeInGB.toFixed(2)} GB`
} }
} }
@@ -93,12 +93,9 @@ export function StorageOverview() {
const fetchStorageData = async () => { const fetchStorageData = async () => {
try { try {
const baseUrl =
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
const [storageResponse, proxmoxResponse] = await Promise.all([ const [storageResponse, proxmoxResponse] = await Promise.all([
fetch(`${baseUrl}/api/storage`), fetch(getApiUrl("/api/storage")),
fetch(`${baseUrl}/api/proxmox-storage`), fetch(getApiUrl("/api/proxmox-storage")),
]) ])
const data = await storageResponse.json() const data = await storageResponse.json()
@@ -107,6 +104,24 @@ export function StorageOverview() {
console.log("[v0] Storage data received:", data) console.log("[v0] Storage data received:", data)
console.log("[v0] Proxmox storage data received:", proxmoxData) 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) {
@@ -402,15 +417,22 @@ 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 && proxmoxStorage.storage
? proxmoxStorage.storage ? proxmoxStorage.storage
.filter( .filter(
(storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active", (storage) =>
storage &&
storage.total > 0 &&
storage.used >= 0 && // Added check for valid used value
storage.status &&
storage.status.toLowerCase() === "active",
) )
.reduce((sum, storage) => sum + storage.used, 0) .reduce((sum, storage) => sum + storage.used, 0)
: 0 : 0
// Convert storageData.total from TB to GB before calculating percentage
const usagePercent = const usagePercent =
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00" storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
@@ -520,7 +542,14 @@ export function StorageOverview() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{proxmoxStorage.storage {proxmoxStorage.storage
.filter((storage) => storage && storage.name && storage.total > 0) .filter(
(storage) =>
storage &&
storage.name &&
storage.total > 0 &&
storage.used >= 0 && // Ensure used is not negative
storage.available >= 0, // Ensure available is not negative
)
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((storage) => ( .map((storage) => (
<div key={storage.name} className="border rounded-lg p-4"> <div key={storage.name} className="border rounded-lg p-4">
@@ -568,7 +597,7 @@ export function StorageOverview() {
<div className="grid grid-cols-3 gap-4 text-sm"> <div className="grid grid-cols-3 gap-4 text-sm">
<div> <div>
<p className="text-muted-foreground">Total</p> <p className="text-muted-foreground">Total</p>
<p className="font-medium">{storage.total.toLocaleString()} GB</p> <p className="font-medium">{formatStorage(storage.total)}</p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">Used</p> <p className="text-muted-foreground">Used</p>
@@ -581,12 +610,12 @@ export function StorageOverview() {
: "text-blue-400" : "text-blue-400"
}`} }`}
> >
{storage.used.toLocaleString()} GB {formatStorage(storage.used)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">Available</p> <p className="text-muted-foreground">Available</p>
<p className="font-medium text-green-400">{storage.available.toLocaleString()} GB</p> <p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -388,12 +388,11 @@ export function SystemOverview() {
if (sizeInGB < 1) { if (sizeInGB < 1) {
// Less than 1 GB, show in MB // Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB` return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB < 1024) { } else if (sizeInGB > 999) {
// Less than 1024 GB, show in GB
return `${sizeInGB.toFixed(1)} GB`
} else {
// 1024 GB or more, show in TB
return `${(sizeInGB / 1024).toFixed(2)} TB` return `${(sizeInGB / 1024).toFixed(2)} TB`
} else {
// Between 1 and 999 GB, show in GB
return `${sizeInGB.toFixed(2)} GB`
} }
} }

View File

@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className, className,
)} )}
ref={ref} ref={ref}

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -25,6 +25,7 @@ import {
} from "lucide-react" } from "lucide-react"
import useSWR from "swr" import useSWR from "swr"
import { MetricsView } from "./metrics-dialog" import { MetricsView } from "./metrics-dialog"
import { formatStorage } from "@/lib/utils" // Import formatStorage utility
interface VMData { interface VMData {
vmid: number vmid: number
@@ -194,18 +195,18 @@ const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_in
return "DHCP" return "DHCP"
} }
const formatStorage = (sizeInGB: number): string => { // const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) { // if (sizeInGB < 1) {
// Less than 1 GB, show in MB // // Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB` // return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB < 1024) { // } else if (sizeInGB < 1024) {
// Less than 1024 GB, show in GB // // Less than 1024 GB, show in GB
return `${sizeInGB.toFixed(1)} GB` // return `${sizeInGB.toFixed(1)} GB`
} else { // } else {
// 1024 GB or more, show in TB // // 1024 GB or more, show in TB
return `${(sizeInGB / 1024).toFixed(1)} TB` // return `${(sizeInGB / 1024).toFixed(1)} TB`
} // }
} // }
const getUsageColor = (percent: number): string => { const getUsageColor = (percent: number): string => {
if (percent >= 95) return "text-red-500" if (percent >= 95) return "text-red-500"

View File

@@ -11,21 +11,29 @@
*/ */
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
if (typeof window === "undefined") { if (typeof window === "undefined") {
console.log("[v0] getApiBaseUrl: Running on server (SSR)")
return "" return ""
} }
const { protocol, hostname, port } = window.location const { protocol, hostname, port } = window.location
console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy // If accessing via standard ports (80/443) or no port, assume we're behind a proxy
// In this case, use relative URLs so the proxy handles routing // In this case, use relative URLs so the proxy handles routing
const isStandardPort = port === "" || port === "80" || port === "443" const isStandardPort = port === "" || port === "80" || port === "443"
console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
if (isStandardPort) { if (isStandardPort) {
// Behind a proxy - use relative URL // Behind a proxy - use relative URL
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
return "" return ""
} else { } else {
// Direct access - use explicit port 8008 // Direct access - use explicit port 8008
return `${protocol}//${hostname}:8008` const baseUrl = `${protocol}//${hostname}:8008`
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
return baseUrl
} }
} }

View File

@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function formatStorage(sizeInGB: number): string {
if (sizeInGB < 1) {
// Less than 1 GB, show in MB
const mb = sizeInGB * 1024
return `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`
} else if (sizeInGB < 1024) {
// Less than 1024 GB, show in GB
return `${sizeInGB % 1 === 0 ? sizeInGB.toFixed(0) : sizeInGB.toFixed(1)} GB`
} else {
// 1024 GB or more, show in TB
const tb = sizeInGB / 1024
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
}
}

View File

@@ -0,0 +1,279 @@
"""
Authentication Manager Module
Handles all authentication-related operations including:
- Loading/saving auth configuration
- Password hashing and verification
- JWT token generation and validation
- Auth status checking
"""
import os
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
try:
import jwt
JWT_AVAILABLE = True
except ImportError:
JWT_AVAILABLE = False
print("Warning: PyJWT not available. Authentication features will be limited.")
# Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production"
JWT_ALGORITHM = "HS256"
TOKEN_EXPIRATION_HOURS = 24
def ensure_config_dir():
"""Ensure the configuration directory exists"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def load_auth_config():
"""
Load authentication configuration from file
Returns dict with structure:
{
"enabled": bool,
"username": str,
"password_hash": str,
"declined": bool, # True if user explicitly declined auth
"configured": bool # True if auth has been set up (enabled or declined)
}
"""
if not AUTH_CONFIG_FILE.exists():
return {
"enabled": False,
"username": None,
"password_hash": None,
"declined": False,
"configured": False
}
try:
with open(AUTH_CONFIG_FILE, 'r') as f:
config = json.load(f)
# Ensure all required fields exist
config.setdefault("declined", False)
config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
return config
except Exception as e:
print(f"Error loading auth config: {e}")
return {
"enabled": False,
"username": None,
"password_hash": None,
"declined": False,
"configured": False
}
def save_auth_config(config):
"""Save authentication configuration to file"""
ensure_config_dir()
try:
with open(AUTH_CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception as e:
print(f"Error saving auth config: {e}")
return False
def hash_password(password):
"""Hash a password using SHA-256"""
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password, password_hash):
"""Verify a password against its hash"""
return hash_password(password) == password_hash
def generate_token(username):
"""Generate a JWT token for the given username"""
if not JWT_AVAILABLE:
return None
payload = {
'username': username,
'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS),
'iat': datetime.utcnow()
}
try:
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token
except Exception as e:
print(f"Error generating token: {e}")
return None
def verify_token(token):
"""
Verify a JWT token
Returns username if valid, None otherwise
"""
if not JWT_AVAILABLE or not token:
return None
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('username')
except jwt.ExpiredSignatureError:
print("Token has expired")
return None
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
return None
def get_auth_status():
"""
Get current authentication status
Returns dict with:
{
"auth_enabled": bool,
"auth_configured": bool,
"declined": bool,
"username": str or None,
"authenticated": bool
}
"""
config = load_auth_config()
return {
"auth_enabled": config.get("enabled", False),
"auth_configured": config.get("configured", False), # Frontend expects this field name
"declined": config.get("declined", False),
"username": config.get("username") if config.get("enabled") else None,
"authenticated": False # Will be set to True by the route handler if token is valid
}
def setup_auth(username, password):
"""
Set up authentication with username and password
Returns (success: bool, message: str)
"""
if not username or not password:
return False, "Username and password are required"
if len(password) < 6:
return False, "Password must be at least 6 characters"
config = {
"enabled": True,
"username": username,
"password_hash": hash_password(password),
"declined": False,
"configured": True
}
if save_auth_config(config):
return True, "Authentication configured successfully"
else:
return False, "Failed to save authentication configuration"
def decline_auth():
"""
Mark authentication as declined by user
Returns (success: bool, message: str)
"""
config = load_auth_config()
config["enabled"] = False
config["declined"] = True
config["configured"] = True
config["username"] = None
config["password_hash"] = None
if save_auth_config(config):
return True, "Authentication declined"
else:
return False, "Failed to save configuration"
def disable_auth():
"""
Disable authentication (different from decline - can be re-enabled)
Returns (success: bool, message: str)
"""
config = load_auth_config()
config["enabled"] = False
# Keep configured=True and don't set declined=True
# This allows re-enabling without showing the setup modal again
if save_auth_config(config):
return True, "Authentication disabled"
else:
return False, "Failed to save configuration"
def enable_auth():
"""
Enable authentication (must already be configured)
Returns (success: bool, message: str)
"""
config = load_auth_config()
if not config.get("username") or not config.get("password_hash"):
return False, "Authentication not configured. Please set up username and password first."
config["enabled"] = True
config["declined"] = False
if save_auth_config(config):
return True, "Authentication enabled"
else:
return False, "Failed to save configuration"
def change_password(old_password, new_password):
"""
Change the authentication password
Returns (success: bool, message: str)
"""
config = load_auth_config()
if not config.get("enabled"):
return False, "Authentication is not enabled"
if not verify_password(old_password, config.get("password_hash", "")):
return False, "Current password is incorrect"
if len(new_password) < 6:
return False, "New password must be at least 6 characters"
config["password_hash"] = hash_password(new_password)
if save_auth_config(config):
return True, "Password changed successfully"
else:
return False, "Failed to save new password"
def authenticate(username, password):
"""
Authenticate a user with username and password
Returns (success: bool, token: str or None, message: str)
"""
config = load_auth_config()
if not config.get("enabled"):
return False, None, "Authentication is not enabled"
if username != config.get("username"):
return False, None, "Invalid username or password"
if not verify_password(password, config.get("password_hash", "")):
return False, None, "Invalid username or password"
token = generate_token(username)
if token:
return True, token, "Authentication successful"
else:
return False, None, "Failed to generate authentication token"

View File

@@ -78,6 +78,10 @@ cd "$SCRIPT_DIR"
# Copy Flask server # Copy Flask server
echo "📋 Copying Flask server..." echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
echo "📋 Adding translation support..." echo "📋 Adding translation support..."
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
@@ -279,6 +283,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
flask-cors \ flask-cors \
psutil \ psutil \
requests \ requests \
PyJWT \
googletrans==4.0.0-rc1 \ googletrans==4.0.0-rc1 \
httpx==0.13.3 \ httpx==0.13.3 \
httpcore==0.9.1 \ httpcore==0.9.1 \
@@ -321,10 +326,6 @@ echo "🔧 Installing hardware monitoring tools..."
mkdir -p "$WORK_DIR/debs" mkdir -p "$WORK_DIR/debs"
cd "$WORK_DIR/debs" cd "$WORK_DIR/debs"
# ==============================================================
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..." echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
dl_pkg() { dl_pkg() {
@@ -361,21 +362,12 @@ dl_pkg() {
return 1 return 1
} }
mkdir -p "$WORK_DIR/debs"
cd "$WORK_DIR/debs"
dl_pkg "ipmitool.deb" "ipmitool" || true dl_pkg "ipmitool.deb" "ipmitool" || true
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
dl_pkg "lm-sensors.deb" "lm-sensors" || true dl_pkg "lm-sensors.deb" "lm-sensors" || true
dl_pkg "nut-client.deb" "nut-client" || true dl_pkg "nut-client.deb" "nut-client" || true
dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true
# dl_pkg "nvidia-smi.deb" "nvidia-smi" "nvidia-utils" "nvidia-utils-535" "nvidia-utils-550" || true
# dl_pkg "intel-gpu-tools.deb" "intel-gpu-tools" || true
# dl_pkg "radeontop.deb" "radeontop" || true
echo "📦 Extracting .deb packages into AppDir..." echo "📦 Extracting .deb packages into AppDir..."
extracted_count=0 extracted_count=0
shopt -s nullglob shopt -s nullglob
@@ -395,7 +387,6 @@ else
echo "✅ Extracted $extracted_count package(s)" echo "✅ Extracted $extracted_count package(s)"
fi fi
if [ -d "$APP_DIR/bin" ]; then if [ -d "$APP_DIR/bin" ]; then
echo "📋 Normalizing /bin -> /usr/bin" echo "📋 Normalizing /bin -> /usr/bin"
mkdir -p "$APP_DIR/usr/bin" mkdir -p "$APP_DIR/usr/bin"
@@ -403,24 +394,20 @@ if [ -d "$APP_DIR/bin" ]; then
rm -rf "$APP_DIR/bin" rm -rf "$APP_DIR/bin"
fi fi
echo "🔍 Sanity check (ldd + presence of libfreeipmi)" echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu" export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)" echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
exit 1 exit 1
fi fi
if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then
echo "❌ ipmitool has unresolved libs:" echo "❌ ipmitool has unresolved libs:"
ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true
exit 1 exit 1
fi fi
if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
echo "⚠️ upsc has unresolved libs, trying to auto-fix..." echo "⚠️ upsc has unresolved libs, trying to auto-fix..."
missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')" missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')"
@@ -463,12 +450,6 @@ echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
[ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing" [ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing"
[ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing" [ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing"
# ==============================================================
# Build AppImage # Build AppImage
echo "🔨 Building unified AppImage v${VERSION}..." echo "🔨 Building unified AppImage v${VERSION}..."
cd "$WORK_DIR" cd "$WORK_DIR"

View File

@@ -0,0 +1,121 @@
"""
Flask Authentication Routes
Provides REST API endpoints for authentication management
"""
from flask import Blueprint, jsonify, request
import auth_manager
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/api/auth/status', methods=['GET'])
def auth_status():
"""Get current authentication status"""
try:
status = auth_manager.get_auth_status()
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if token:
username = auth_manager.verify_token(token)
if username:
status['authenticated'] = True
return jsonify(status)
except Exception as e:
return jsonify({"error": str(e)}), 500
@auth_bp.route('/api/auth/setup', methods=['POST'])
def auth_setup():
"""Set up authentication with username and password"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
success, message = auth_manager.setup_auth(username, password)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup"""
try:
success, message = auth_manager.decline_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/login', methods=['POST'])
def auth_login():
"""Authenticate user and return JWT token"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
success, token, message = auth_manager.authenticate(username, password)
if success:
return jsonify({"success": True, "token": token, "message": message})
else:
return jsonify({"success": False, "message": message}), 401
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/enable', methods=['POST'])
def auth_enable():
"""Enable authentication"""
try:
success, message = auth_manager.enable_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/disable', methods=['POST'])
def auth_disable():
"""Disable authentication"""
try:
success, message = auth_manager.disable_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/change-password', methods=['POST'])
def auth_change_password():
"""Change authentication password"""
try:
data = request.json
old_password = data.get('old_password')
new_password = data.get('new_password')
success, message = auth_manager.change_password(old_password, new_password)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500

View File

@@ -0,0 +1,26 @@
"""
Flask routes for health monitoring
"""
from flask import Blueprint, jsonify
from health_monitor import health_monitor
health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/status', methods=['GET'])
def get_health_status():
"""Get overall health status summary"""
try:
status = health_monitor.get_overall_status()
return jsonify(status)
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/health/details', methods=['GET'])
def get_health_details():
"""Get detailed health status with all checks"""
try:
details = health_monitor.get_detailed_status()
return jsonify(details)
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -12,6 +12,7 @@ import psutil
import subprocess import subprocess
import json import json
import os import os
import sys
import time import time
import socket import socket
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -22,10 +23,26 @@ import xml.etree.ElementTree as ET # Added for XML parsing
import math # Imported math for format_bytes function import math # Imported math for format_bytes function
import urllib.parse # Added for URL encoding import urllib.parse # Added for URL encoding
import platform # Added for platform.release() import platform # Added for platform.release()
import hashlib
import secrets
import jwt
from functools import wraps
from pathlib import Path
from flask_health_routes import health_bp
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask_auth_routes import auth_bp
app = Flask(__name__) 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(health_bp)
def identify_gpu_type(name, vendor=None, bus=None, driver=None): def identify_gpu_type(name, vendor=None, bus=None, driver=None):
""" """
Returns: 'Integrated' or 'PCI' (discrete) Returns: 'Integrated' or 'PCI' (discrete)
@@ -395,18 +412,18 @@ def get_intel_gpu_processes_from_text():
processes.append(process_info) processes.append(process_info)
except (ValueError, IndexError) as e: except (ValueError, IndexError) as e:
# print(f"[v0] Error parsing process line: {e}", flush=True) # print(f"[v0] Error parsing process line: {e}")
pass pass
continue continue
break break
if not header_found: if not header_found:
# print(f"[v0] No process table found in intel_gpu_top output", flush=True) # print(f"[v0] No process table found in intel_gpu_top output")
pass pass
return processes return processes
except Exception as e: except Exception as e:
# print(f"[v0] Error getting processes from intel_gpu_top text: {e}", flush=True) # print(f"[v0] Error getting processes from intel_gpu_top text: {e}")
pass pass
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -917,6 +934,183 @@ def get_disk_hardware_info(disk_name):
"""Placeholder for disk hardware info - to be populated by lsblk later.""" """Placeholder for disk hardware info - to be populated by lsblk later."""
return {} return {}
def get_pcie_link_speed(disk_name):
"""Get PCIe link speed information for NVMe drives"""
pcie_info = {
'pcie_gen': None,
'pcie_width': None,
'pcie_max_gen': None,
'pcie_max_width': None
}
try:
# For NVMe drives, get PCIe information from sysfs
if disk_name.startswith('nvme'):
# Extract controller name properly using regex
import re
match = re.match(r'(nvme\d+)n\d+', disk_name)
if not match:
print(f"[v0] Could not extract controller from {disk_name}")
return pcie_info
controller = match.group(1) # nvme0n1 -> nvme0
print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
# Path to PCIe device in sysfs
sys_path = f'/sys/class/nvme/{controller}/device'
print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
if os.path.exists(sys_path):
try:
pci_address = os.path.basename(os.readlink(sys_path))
print(f"[v0] PCI address for {disk_name}: {pci_address}")
# Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(f"[v0] lspci output for {pci_address}:")
for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}")
# Example: "LnkSta: Speed 8GT/s, Width x4"
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
gt_s = float(speed_match.group(1))
if gt_s <= 2.5:
pcie_info['pcie_gen'] = '1.0'
elif gt_s <= 5.0:
pcie_info['pcie_gen'] = '2.0'
elif gt_s <= 8.0:
pcie_info['pcie_gen'] = '3.0'
elif gt_s <= 16.0:
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
gt_s = float(speed_match.group(1))
if gt_s <= 2.5:
pcie_info['pcie_max_gen'] = '1.0'
elif gt_s <= 5.0:
pcie_info['pcie_max_gen'] = '2.0'
elif gt_s <= 8.0:
pcie_info['pcie_max_gen'] = '3.0'
elif gt_s <= 16.0:
pcie_info['pcie_max_gen'] = '4.0'
else:
pcie_info['pcie_max_gen'] = '5.0'
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
except Exception as e:
print(f"[v0] Error getting PCIe info via lspci: {e}")
import traceback
traceback.print_exc()
else:
print(f"[v0] sys_path does not exist: {sys_path}")
alt_sys_path = f'/sys/block/{disk_name}/device/device'
print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
if os.path.exists(alt_sys_path):
try:
# Get PCI address from the alternative path
pci_address = os.path.basename(os.readlink(alt_sys_path))
print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
# Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(f"[v0] lspci output for {pci_address} (from alt path):")
for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}")
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
gt_s = float(speed_match.group(1))
if gt_s <= 2.5:
pcie_info['pcie_gen'] = '1.0'
elif gt_s <= 5.0:
pcie_info['pcie_gen'] = '2.0'
elif gt_s <= 8.0:
pcie_info['pcie_gen'] = '3.0'
elif gt_s <= 16.0:
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
gt_s = float(speed_match.group(1))
if gt_s <= 2.5:
pcie_info['pcie_max_gen'] = '1.0'
elif gt_s <= 5.0:
pcie_info['pcie_max_gen'] = '2.0'
elif gt_s <= 8.0:
pcie_info['pcie_max_gen'] = '3.0'
elif gt_s <= 16.0:
pcie_info['pcie_max_gen'] = '4.0'
else:
pcie_info['pcie_max_gen'] = '5.0'
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
except Exception as e:
print(f"[v0] Error getting PCIe info from alt path: {e}")
import traceback
traceback.print_exc()
except Exception as e:
print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
import traceback
traceback.print_exc()
print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
return pcie_info
# get_pcie_link_speed function definition ends here
def get_smart_data(disk_name): def get_smart_data(disk_name):
"""Get SMART data for a specific disk - Enhanced with multiple device type attempts""" """Get SMART data for a specific disk - Enhanced with multiple device type attempts"""
smart_data = { smart_data = {
@@ -947,8 +1141,8 @@ def get_smart_data(disk_name):
try: try:
commands_to_try = [ commands_to_try = [
['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON output (preferred) ['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON output (preferred)
['smartctl', '-a', '-j', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type ['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type
['smartctl', '-a', '-j', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type ['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback) ['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback)
['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # Text with ATA device type ['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # Text with ATA device type
['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # Text with SAT device type ['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # Text with SAT device type
@@ -2013,7 +2207,7 @@ def get_proxmox_vms():
# print(f"[v0] Error getting VM/LXC info: {e}") # print(f"[v0] Error getting VM/LXC info: {e}")
pass pass
return { return {
'error': f'Unable to access VM information: {str(e)}', 'error': 'Unable to access VM information: {str(e)}',
'vms': [] 'vms': []
} }
except Exception as e: except Exception as e:
@@ -2548,7 +2742,7 @@ def get_detailed_gpu_info(gpu):
if 'clients' in json_data: if 'clients' in json_data:
client_count = len(json_data['clients']) client_count = len(json_data['clients'])
for client_id, client_data in json_data['clients'].items(): for client_id, client_data in json_data['clients']:
client_name = client_data.get('name', 'Unknown') client_name = client_data.get('name', 'Unknown')
client_pid = client_data.get('pid', 'Unknown') client_pid = client_data.get('pid', 'Unknown')
@@ -2630,7 +2824,7 @@ def get_detailed_gpu_info(gpu):
clients = best_json['clients'] clients = best_json['clients']
processes = [] processes = []
for client_id, client_data in clients.items(): for client_id, client_data in clients:
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'),
@@ -3091,22 +3285,22 @@ def get_detailed_gpu_info(gpu):
# print(f"[v0] Temperature: {detailed_info['temperature']}°C", flush=True) # print(f"[v0] Temperature: {detailed_info['temperature']}°C", flush=True)
pass pass
data_retrieved = True data_retrieved = True
# Parse power draw (GFX Power or average_socket_power) # Parse power draw (GFX Power or average_socket_power)
if 'GFX Power' in sensors: if '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 '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"
# 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
# Parse clocks (GFX_SCLK for graphics, GFX_MCLK for memory) # Parse clocks (GFX_SCLK for graphics, GFX_MCLK for memory)
if 'Clocks' in device: if 'Clocks' in device:
@@ -3115,7 +3309,7 @@ def get_detailed_gpu_info(gpu):
gfx_clock = clocks['GFX_SCLK'] gfx_clock = clocks['GFX_SCLK']
if 'value' in gfx_clock: if 'value' in gfx_clock:
detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz" detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz"
# print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']}", flush=True) # print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']} MHz", flush=True)
pass pass
data_retrieved = True data_retrieved = True
@@ -3123,7 +3317,7 @@ def get_detailed_gpu_info(gpu):
mem_clock = clocks['GFX_MCLK'] mem_clock = clocks['GFX_MCLK']
if 'value' in mem_clock: if 'value' in mem_clock:
detailed_info['clock_memory'] = f"{mem_clock['value']} MHz" detailed_info['clock_memory'] = f"{mem_clock['value']} MHz"
# print(f"[v0] Memory Clock: {detailed_info['clock_memory']}", flush=True) # print(f"[v0] Memory Clock: {detailed_info['clock_memory']} MHz", flush=True)
pass pass
data_retrieved = True data_retrieved = True
@@ -3360,7 +3554,6 @@ def get_detailed_gpu_info(gpu):
else: else:
# print(f"[v0] No fdinfo section found in device data", flush=True) # print(f"[v0] No fdinfo section found in device data", flush=True)
pass pass
detailed_info['processes'] = []
if data_retrieved: if data_retrieved:
detailed_info['has_monitoring_tool'] = True detailed_info['has_monitoring_tool'] = True
@@ -3886,6 +4079,10 @@ def get_hardware_info():
except: except:
pass pass
pcie_info = {}
if disk_name.startswith('nvme'):
pcie_info = get_pcie_link_speed(disk_name)
# Build storage device with all available information # Build storage device with all available information
storage_device = { storage_device = {
'name': disk_name, 'name': disk_name,
@@ -3901,6 +4098,9 @@ def get_hardware_info():
'sata_version': sata_version, 'sata_version': sata_version,
} }
if pcie_info:
storage_device.update(pcie_info)
# Add family if available (from smartctl) # Add family if available (from smartctl)
try: try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'], result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
@@ -3922,7 +4122,7 @@ def get_hardware_info():
# print(f"[v0] Error getting storage info: {e}") # print(f"[v0] Error getting storage info: {e}")
pass pass
# Graphics Cards (from lspci - will be duplicated by new PCI device listing, but kept for now) # Graphics Cards
try: try:
# Try nvidia-smi first # Try nvidia-smi first
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'], result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'],
@@ -4467,7 +4667,7 @@ def api_network_interface_metrics(interface_name):
for point in all_data: for point in all_data:
filtered_point = {'time': point.get('time')} filtered_point = {'time': point.get('time')}
# Add network fields if they exist # Add network fields if they exist
for key in ['netin', 'netout', 'diskread', 'diskwrite']: for key in ['netin', 'netout']:
if key in point: if key in point:
filtered_point[key] = point[key] filtered_point[key] = point[key]
rrd_data.append(filtered_point) rrd_data.append(filtered_point)
@@ -5379,10 +5579,6 @@ def api_prometheus():
mem_used_bytes = mem_used * 1024 * 1024 # Convert MiB to bytes mem_used_bytes = mem_used * 1024 * 1024 # Convert MiB to bytes
mem_total_bytes = mem_total * 1024 * 1024 mem_total_bytes = mem_total * 1024 * 1024
metrics.append(f'# HELP proxmox_gpu_memory_used_bytes GPU memory used in bytes')
metrics.append(f'# TYPE proxmox_gpu_memory_used_bytes gauge')
metrics.append(f'proxmox_gpu_memory_used_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_used_bytes} {timestamp}')
metrics.append(f'# HELP proxmox_gpu_memory_total_bytes GPU memory total in bytes') metrics.append(f'# HELP proxmox_gpu_memory_total_bytes GPU memory total in bytes')
metrics.append(f'# TYPE proxmox_gpu_memory_total_bytes gauge') metrics.append(f'# TYPE proxmox_gpu_memory_total_bytes gauge')
metrics.append(f'proxmox_gpu_memory_total_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_total_bytes} {timestamp}') metrics.append(f'proxmox_gpu_memory_total_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_total_bytes} {timestamp}')
@@ -5475,7 +5671,7 @@ def api_system_info():
except: except:
pass pass
# Try to get node info from Proxmox API # Try to get node info from Proxmox API
try: try:
result = subprocess.run(['pvesh', 'get', '/nodes', '--output-format', 'json'], result = subprocess.run(['pvesh', 'get', '/nodes', '--output-format', 'json'],
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=5)

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,13 @@ export interface StorageDevice {
rotation_rate?: number | string rotation_rate?: number | string
form_factor?: string form_factor?: string
sata_version?: string sata_version?: string
pcie_gen?: string // e.g., "PCIe 4.0"
pcie_width?: string // e.g., "x4"
pcie_max_gen?: string // Maximum supported PCIe generation
pcie_max_width?: string // Maximum supported PCIe lanes
sas_version?: string // e.g., "SAS-3"
sas_speed?: string // e.g., "12Gb/s"
link_speed?: string // Generic link speed info
} }
export interface PCIDevice { export interface PCIDevice {