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`
}
// Convert to GB if >= 1024 MB
if (mb >= 1024) {
const gb = mb / 1024
// If GB value is greater than 999, convert to TB
if (gb > 999) {
return `${(gb / 1024).toFixed(2)} TB`
}
return `${gb.toFixed(1)} GB`
}
@@ -168,6 +171,22 @@ export default function Hardware() {
refreshInterval: 5000,
})
useEffect(() => {
if (hardwareData?.storage_devices) {
console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)
hardwareData.storage_devices.forEach((device) => {
if (device.name.startsWith("nvme")) {
console.log(`[v0] NVMe device ${device.name}:`, {
pcie_gen: device.pcie_gen,
pcie_width: device.pcie_width,
pcie_max_gen: device.pcie_max_gen,
pcie_max_width: device.pcie_max_width,
})
}
})
}
}, [hardwareData])
const [selectedGPU, setSelectedGPU] = useState<GPU | null>(null)
const [realtimeGPUData, setRealtimeGPUData] = useState<any>(null)
const [detailsLoading, setDetailsLoading] = useState(false)
@@ -1658,6 +1677,61 @@ export default function Hardware() {
const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate)
const getLinkSpeedInfo = (device: StorageDevice) => {
// NVMe PCIe information
if (device.name.startsWith("nvme") && (device.pcie_gen || device.pcie_width)) {
const current = `${device.pcie_gen || ""} ${device.pcie_width || ""}`.trim()
const max =
device.pcie_max_gen && device.pcie_max_width
? `${device.pcie_max_gen} ${device.pcie_max_width}`.trim()
: null
const isLowerSpeed = max && current !== max
return {
text: current || null,
maxText: max,
isWarning: isLowerSpeed,
color: isLowerSpeed ? "text-orange-500" : "text-blue-500",
}
}
// SATA information
if (device.sata_version) {
return {
text: device.sata_version,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
// SAS information
if (device.sas_version || device.sas_speed) {
const text = [device.sas_version, device.sas_speed].filter(Boolean).join(" ")
return {
text: text || null,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
// Generic link speed
if (device.link_speed) {
return {
text: device.link_speed,
maxText: null,
isWarning: false,
color: "text-blue-500",
}
}
return null
}
const linkSpeed = getLinkSpeedInfo(device)
return (
<div
key={index}
@@ -1672,6 +1746,14 @@ export default function Hardware() {
{device.model && (
<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>
)
})}
@@ -1695,46 +1777,44 @@ export default function Hardware() {
<span className="font-mono text-sm">{selectedDisk.name}</span>
</div>
{selectedDisk.name && (
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Type</span>
{(() => {
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
let diskType = "HDD"
<div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Type</span>
{(() => {
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
let diskType = "HDD"
if (diskName.startsWith("nvme")) {
diskType = "NVMe"
} else if (rotationRate !== undefined && rotationRate !== null) {
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
if (rateNum === 0 || isNaN(rateNum)) {
diskType = "SSD"
}
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
if (diskName.startsWith("nvme")) {
diskType = "NVMe"
} else if (rotationRate !== undefined && rotationRate !== null) {
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
if (rateNum === 0 || isNaN(rateNum)) {
diskType = "SSD"
}
const badgeStyles: Record<string, { className: string; label: string }> = {
NVMe: {
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
label: "NVMe SSD",
},
SSD: {
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
label: "SSD",
},
HDD: {
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
label: "HDD",
},
}
return badgeStyles[diskType]
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
diskType = "SSD"
}
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
})()}
</div>
)}
const badgeStyles: Record<string, { className: string; label: string }> = {
NVMe: {
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
label: "NVMe SSD",
},
SSD: {
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
label: "SSD",
},
HDD: {
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
label: "HDD",
},
}
return badgeStyles[diskType]
}
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
})()}
</div>
{selectedDisk.size && (
<div className="flex justify-between border-b border-border/50 pb-2">
@@ -1743,6 +1823,84 @@ export default function Hardware() {
</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 && (
<div className="flex justify-between border-b border-border/50 pb-2">
<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>
</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>
)}
</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,
FileText,
Rocket,
Zap,
Shield,
Link2,
Gauge,
} from "lucide-react"
import Image from "next/image"
import { Checkbox } from "./ui/checkbox"
interface OnboardingSlide {
id: number
@@ -27,6 +32,7 @@ interface OnboardingSlide {
image?: string
icon: React.ReactNode
gradient: string
features?: { icon: React.ReactNode; text: string }[]
}
const slides: OnboardingSlide[] = [
@@ -40,6 +46,35 @@ const slides: OnboardingSlide[] = [
},
{
id: 1,
title: "What's New in This Version",
description: "We've added exciting new features and improvements to make ProxMenux Monitor even better!",
icon: <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",
description:
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
@@ -48,7 +83,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-cyan-500",
},
{
id: 2,
id: 3,
title: "Storage Management",
description:
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
@@ -57,7 +92,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-cyan-500 to-teal-500",
},
{
id: 3,
id: 4,
title: "Network Metrics",
description:
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
@@ -66,7 +101,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-teal-500 to-green-500",
},
{
id: 4,
id: 5,
title: "Virtual Machines & Containers",
description:
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
@@ -75,7 +110,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-green-500 to-emerald-500",
},
{
id: 5,
id: 6,
title: "Hardware Information",
description:
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
@@ -84,7 +119,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-emerald-500 to-blue-500",
},
{
id: 6,
id: 7,
title: "System Logs",
description:
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
@@ -93,7 +128,7 @@ const slides: OnboardingSlide[] = [
gradient: "from-blue-500 to-indigo-500",
},
{
id: 7,
id: 8,
title: "Ready for the Future!",
description:
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
@@ -106,6 +141,7 @@ export function OnboardingCarousel() {
const [open, setOpen] = useState(false)
const [currentSlide, setCurrentSlide] = useState(0)
const [direction, setDirection] = useState<"next" | "prev">("next")
const [dontShowAgain, setDontShowAgain] = useState(false)
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
@@ -119,6 +155,9 @@ export function OnboardingCarousel() {
setDirection("next")
setCurrentSlide(currentSlide + 1)
} else {
if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false)
}
}
@@ -131,11 +170,16 @@ export function OnboardingCarousel() {
}
const handleSkip = () => {
if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false)
}
const handleDontShowAgain = () => {
localStorage.setItem("proxmenux-onboarding-seen", "true")
const handleClose = () => {
if (dontShowAgain) {
localStorage.setItem("proxmenux-onboarding-seen", "true")
}
setOpen(false)
}
@@ -147,7 +191,7 @@ export function OnboardingCarousel() {
const slide = slides[currentSlide]
return (
<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">
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
{/* Close button */}
@@ -155,7 +199,7 @@ export function OnboardingCarousel() {
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={handleSkip}
onClick={handleClose}
>
<X className="h-4 w-4" />
</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>
<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">
<h2 className="text-2xl 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">
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
{slide.description}
</p>
</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 */}
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
{slides.map((_, index) => (
@@ -221,12 +279,12 @@ export function OnboardingCarousel() {
))}
</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
variant="ghost"
onClick={handlePrev}
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" />
Previous
@@ -235,10 +293,17 @@ export function OnboardingCarousel() {
<div className="flex gap-2 w-full sm:w-auto">
{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
</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
<ChevronRight className="h-4 w-4" />
</Button>
@@ -246,7 +311,7 @@ export function OnboardingCarousel() {
) : (
<Button
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!
<Sparkles className="h-4 w-4" />
@@ -255,17 +320,19 @@ export function OnboardingCarousel() {
</div>
</div>
{/* Don't show again */}
{currentSlide === slides.length - 1 && (
<div className="text-center pt-2">
<button
onClick={handleDontShowAgain}
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline"
>
Don't show again
</button>
</div>
)}
<div className="flex items-center justify-center gap-2 pt-2 pb-1">
<Checkbox
id="dont-show-again"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
/>
<label
htmlFor="dont-show-again"
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
>
Don't show this again
</label>
</div>
</div>
</div>
</DialogContent>

View File

@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { getApiUrl } from "../lib/api-config"
import {
RefreshCw,
@@ -63,6 +64,7 @@ export function ProxmoxDashboard() {
const [activeTab, setActiveTab] = useState("overview")
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...")
@@ -244,7 +246,10 @@ export function ProxmoxDashboard() {
</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">
{/* Logo and Title */}
<div className="flex items-start justify-between gap-3">
@@ -299,7 +304,10 @@ export function ProxmoxDashboard() {
<Button
variant="outline"
size="sm"
onClick={refreshData}
onClick={(e) => {
e.stopPropagation()
refreshData()
}}
disabled={isRefreshing}
className="border-border/50 bg-transparent hover:bg-secondary"
>
@@ -307,7 +315,9 @@ export function ProxmoxDashboard() {
Refresh
</Button>
<ThemeToggle />
<div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
</div>
{/* Mobile Actions */}
@@ -317,11 +327,22 @@ export function ProxmoxDashboard() {
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
</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" : ""}`} />
</Button>
<ThemeToggle />
<div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
</div>
</div>
@@ -534,6 +555,8 @@ export function ProxmoxDashboard() {
</p>
</footer>
</div>
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
</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 = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
{ name: "Storage", href: "/storage", icon: HardDrive },
{ name: "Network", href: "/network", icon: Network },
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
{ name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section
{ name: "Hardware", href: "/hardware", icon: Cpu },
{ name: "System Logs", href: "/logs", icon: FileText },
{ name: "Settings", href: "/settings", icon: SettingsIcon },
]
// ... existing code ...

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
import { formatStorage } from "@/lib/utils"
interface StorageData {
total: number
@@ -116,10 +117,10 @@ export function StorageMetrics() {
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<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" />
<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>
</CardContent>
</Card>
@@ -130,7 +131,7 @@ export function StorageMetrics() {
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<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" />
<p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p>
</CardContent>
@@ -144,7 +145,7 @@ export function StorageMetrics() {
</CardTitle>
</CardHeader>
<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">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{((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="text-right">
<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>
<Progress value={disk.usage_percent} className="w-24 mt-1" />
</div>

View File

@@ -6,6 +6,7 @@ import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Ther
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { getApiUrl } from "../lib/api-config"
interface DiskInfo {
name: string
@@ -75,12 +76,11 @@ const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) {
// Less than 1 GB, show in MB
return `${(sizeInGB * 1024).toFixed(1)} MB`
} else if (sizeInGB < 1024) {
// Less than 1024 GB, show in GB
return `${sizeInGB.toFixed(1)} GB`
} else if (sizeInGB > 999) {
return `${(sizeInGB / 1024).toFixed(2)} TB`
} else {
// 1024 GB or more, show in TB
return `${(sizeInGB / 1024).toFixed(1)} TB`
// Between 1 and 999 GB, show in GB
return `${sizeInGB.toFixed(2)} GB`
}
}
@@ -93,12 +93,9 @@ export function StorageOverview() {
const fetchStorageData = async () => {
try {
const baseUrl =
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
const [storageResponse, proxmoxResponse] = await Promise.all([
fetch(`${baseUrl}/api/storage`),
fetch(`${baseUrl}/api/proxmox-storage`),
fetch(getApiUrl("/api/storage")),
fetch(getApiUrl("/api/proxmox-storage")),
])
const data = await storageResponse.json()
@@ -107,6 +104,24 @@ export function StorageOverview() {
console.log("[v0] Storage data received:", data)
console.log("[v0] Proxmox storage data received:", proxmoxData)
if (proxmoxData && proxmoxData.storage) {
const activeStorages = proxmoxData.storage.filter(
(s: any) => s && s.total > 0 && s.used >= 0 && s.status?.toLowerCase() === "active",
)
console.log("[v0] Active storage volumes:", activeStorages.length)
console.log(
"[v0] Total used across all volumes (GB):",
activeStorages.reduce((sum: number, s: any) => sum + s.used, 0),
)
// Check for potential cluster node duplication
const storageNames = activeStorages.map((s: any) => s.name)
const uniqueNames = new Set(storageNames)
if (storageNames.length !== uniqueNames.size) {
console.warn("[v0] WARNING: Duplicate storage names detected - possible cluster node issue")
}
}
setStorageData(data)
setProxmoxStorage(proxmoxData)
} catch (error) {
@@ -402,15 +417,22 @@ export function StorageOverview() {
const diskHealthBreakdown = getDiskHealthBreakdown()
const diskTypesBreakdown = getDiskTypesBreakdown()
// Only sum storage that belongs to the current node or filter appropriately
const totalProxmoxUsed =
proxmoxStorage && proxmoxStorage.storage
? proxmoxStorage.storage
.filter(
(storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active",
(storage) =>
storage &&
storage.total > 0 &&
storage.used >= 0 && // Added check for valid used value
storage.status &&
storage.status.toLowerCase() === "active",
)
.reduce((sum, storage) => sum + storage.used, 0)
: 0
// Convert storageData.total from TB to GB before calculating percentage
const usagePercent =
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
@@ -520,7 +542,14 @@ export function StorageOverview() {
<CardContent>
<div className="space-y-4">
{proxmoxStorage.storage
.filter((storage) => storage && storage.name && storage.total > 0)
.filter(
(storage) =>
storage &&
storage.name &&
storage.total > 0 &&
storage.used >= 0 && // Ensure used is not negative
storage.available >= 0, // Ensure available is not negative
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((storage) => (
<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>
<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>
<p className="text-muted-foreground">Used</p>
@@ -581,12 +610,12 @@ export function StorageOverview() {
: "text-blue-400"
}`}
>
{storage.used.toLocaleString()} GB
{formatStorage(storage.used)}
</p>
</div>
<div>
<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>

View File

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

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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