mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-18 11:36:17 +00:00
Merge branch 'MacRimi:main' into main
This commit is contained in:
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
||||||
243
AppImage/components/auth-setup.tsx
Normal file
243
AppImage/components/auth-setup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
269
AppImage/components/health-status-modal.tsx
Normal file
269
AppImage/components/health-status-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
AppImage/components/login.tsx
Normal file
141
AppImage/components/login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
434
AppImage/components/settings.tsx
Normal file
434
AppImage/components/settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ...
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
AppImage/components/ui/checkbox.tsx
Normal file
27
AppImage/components/ui/checkbox.tsx
Normal 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 }
|
||||||
@@ -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}
|
||||||
|
|||||||
17
AppImage/components/ui/label.tsx
Normal file
17
AppImage/components/ui/label.tsx
Normal 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 }
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
279
AppImage/scripts/auth_manager.py
Normal file
279
AppImage/scripts/auth_manager.py
Normal 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"
|
||||||
@@ -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"
|
||||||
|
|||||||
121
AppImage/scripts/flask_auth_routes.py
Normal file
121
AppImage/scripts/flask_auth_routes.py
Normal 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
|
||||||
26
AppImage/scripts/flask_health_routes.py
Normal file
26
AppImage/scripts/flask_health_routes.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
1176
AppImage/scripts/health_monitor.py
Normal file
1176
AppImage/scripts/health_monitor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user