mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-17 19:16:25 +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`
|
||||
}
|
||||
|
||||
// Convert to GB if >= 1024 MB
|
||||
if (mb >= 1024) {
|
||||
const gb = mb / 1024
|
||||
// If GB value is greater than 999, convert to TB
|
||||
if (gb > 999) {
|
||||
return `${(gb / 1024).toFixed(2)} TB`
|
||||
}
|
||||
return `${gb.toFixed(1)} GB`
|
||||
}
|
||||
|
||||
@@ -168,6 +171,22 @@ export default function Hardware() {
|
||||
refreshInterval: 5000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hardwareData?.storage_devices) {
|
||||
console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)
|
||||
hardwareData.storage_devices.forEach((device) => {
|
||||
if (device.name.startsWith("nvme")) {
|
||||
console.log(`[v0] NVMe device ${device.name}:`, {
|
||||
pcie_gen: device.pcie_gen,
|
||||
pcie_width: device.pcie_width,
|
||||
pcie_max_gen: device.pcie_max_gen,
|
||||
pcie_max_width: device.pcie_max_width,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [hardwareData])
|
||||
|
||||
const [selectedGPU, setSelectedGPU] = useState<GPU | null>(null)
|
||||
const [realtimeGPUData, setRealtimeGPUData] = useState<any>(null)
|
||||
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||
@@ -1658,6 +1677,61 @@ export default function Hardware() {
|
||||
|
||||
const diskBadge = getDiskTypeBadge(device.name, device.rotation_rate)
|
||||
|
||||
const getLinkSpeedInfo = (device: StorageDevice) => {
|
||||
// NVMe PCIe information
|
||||
if (device.name.startsWith("nvme") && (device.pcie_gen || device.pcie_width)) {
|
||||
const current = `${device.pcie_gen || ""} ${device.pcie_width || ""}`.trim()
|
||||
const max =
|
||||
device.pcie_max_gen && device.pcie_max_width
|
||||
? `${device.pcie_max_gen} ${device.pcie_max_width}`.trim()
|
||||
: null
|
||||
|
||||
const isLowerSpeed = max && current !== max
|
||||
|
||||
return {
|
||||
text: current || null,
|
||||
maxText: max,
|
||||
isWarning: isLowerSpeed,
|
||||
color: isLowerSpeed ? "text-orange-500" : "text-blue-500",
|
||||
}
|
||||
}
|
||||
|
||||
// SATA information
|
||||
if (device.sata_version) {
|
||||
return {
|
||||
text: device.sata_version,
|
||||
maxText: null,
|
||||
isWarning: false,
|
||||
color: "text-blue-500",
|
||||
}
|
||||
}
|
||||
|
||||
// SAS information
|
||||
if (device.sas_version || device.sas_speed) {
|
||||
const text = [device.sas_version, device.sas_speed].filter(Boolean).join(" ")
|
||||
return {
|
||||
text: text || null,
|
||||
maxText: null,
|
||||
isWarning: false,
|
||||
color: "text-blue-500",
|
||||
}
|
||||
}
|
||||
|
||||
// Generic link speed
|
||||
if (device.link_speed) {
|
||||
return {
|
||||
text: device.link_speed,
|
||||
maxText: null,
|
||||
isWarning: false,
|
||||
color: "text-blue-500",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const linkSpeed = getLinkSpeedInfo(device)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
@@ -1672,6 +1746,14 @@ export default function Hardware() {
|
||||
{device.model && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 break-words">{device.model}</p>
|
||||
)}
|
||||
{linkSpeed && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className={`text-xs font-medium ${linkSpeed.color}`}>{linkSpeed.text}</span>
|
||||
{linkSpeed.maxText && linkSpeed.isWarning && (
|
||||
<span className="text-xs font-medium text-blue-500">(max: {linkSpeed.maxText})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1695,46 +1777,44 @@ export default function Hardware() {
|
||||
<span className="font-mono text-sm">{selectedDisk.name}</span>
|
||||
</div>
|
||||
|
||||
{selectedDisk.name && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Type</span>
|
||||
{(() => {
|
||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
||||
let diskType = "HDD"
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Type</span>
|
||||
{(() => {
|
||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
|
||||
let diskType = "HDD"
|
||||
|
||||
if (diskName.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (rotationRate !== undefined && rotationRate !== null) {
|
||||
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
|
||||
if (rateNum === 0 || isNaN(rateNum)) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
|
||||
if (diskName.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (rotationRate !== undefined && rotationRate !== null) {
|
||||
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate
|
||||
if (rateNum === 0 || isNaN(rateNum)) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
|
||||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||||
NVMe: {
|
||||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
label: "NVMe SSD",
|
||||
},
|
||||
SSD: {
|
||||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||||
label: "SSD",
|
||||
},
|
||||
HDD: {
|
||||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
label: "HDD",
|
||||
},
|
||||
}
|
||||
return badgeStyles[diskType]
|
||||
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
|
||||
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
|
||||
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||||
NVMe: {
|
||||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
label: "NVMe SSD",
|
||||
},
|
||||
SSD: {
|
||||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||||
label: "SSD",
|
||||
},
|
||||
HDD: {
|
||||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
label: "HDD",
|
||||
},
|
||||
}
|
||||
return badgeStyles[diskType]
|
||||
}
|
||||
|
||||
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
|
||||
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{selectedDisk.size && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
@@ -1743,6 +1823,84 @@ export default function Hardware() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
Interface Information
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* NVMe PCIe Information */}
|
||||
{selectedDisk.name.startsWith("nvme") && (
|
||||
<>
|
||||
{selectedDisk.pcie_gen || selectedDisk.pcie_width ? (
|
||||
<>
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Current Link Speed</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
selectedDisk.pcie_max_gen &&
|
||||
selectedDisk.pcie_max_width &&
|
||||
`${selectedDisk.pcie_gen} ${selectedDisk.pcie_width}` !==
|
||||
`${selectedDisk.pcie_max_gen} ${selectedDisk.pcie_max_width}`
|
||||
? "text-orange-500"
|
||||
: "text-blue-500"
|
||||
}`}
|
||||
>
|
||||
{selectedDisk.pcie_gen || "PCIe"} {selectedDisk.pcie_width || ""}
|
||||
</span>
|
||||
</div>
|
||||
{selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Maximum Link Speed</span>
|
||||
<span className="text-sm font-medium text-blue-500">
|
||||
{selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">PCIe Link Speed</span>
|
||||
<span className="text-sm text-muted-foreground italic">Detecting...</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SATA Information */}
|
||||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SATA Version</span>
|
||||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sata_version}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SAS Information */}
|
||||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SAS Version</span>
|
||||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_version}</span>
|
||||
</div>
|
||||
)}
|
||||
{!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SAS Speed</span>
|
||||
<span className="text-sm font-medium text-blue-500">{selectedDisk.sas_speed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic Link Speed - only show if no specific interface info */}
|
||||
{!selectedDisk.name.startsWith("nvme") &&
|
||||
selectedDisk.link_speed &&
|
||||
!selectedDisk.pcie_gen &&
|
||||
!selectedDisk.sata_version &&
|
||||
!selectedDisk.sas_version && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Link Speed</span>
|
||||
<span className="text-sm font-medium text-blue-500">{selectedDisk.link_speed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDisk.model && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Model</span>
|
||||
@@ -1806,13 +1964,6 @@ export default function Hardware() {
|
||||
<span className="text-sm">{selectedDisk.form_factor}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDisk.sata_version && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SATA Version</span>
|
||||
<span className="text-sm">{selectedDisk.sata_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
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,
|
||||
FileText,
|
||||
Rocket,
|
||||
Zap,
|
||||
Shield,
|
||||
Link2,
|
||||
Gauge,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
@@ -27,6 +32,7 @@ interface OnboardingSlide {
|
||||
image?: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
features?: { icon: React.ReactNode; text: string }[]
|
||||
}
|
||||
|
||||
const slides: OnboardingSlide[] = [
|
||||
@@ -40,6 +46,35 @@ const slides: OnboardingSlide[] = [
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "What's New in This Version",
|
||||
description: "We've added exciting new features and improvements to make ProxMenux Monitor even better!",
|
||||
icon: <Zap className="h-16 w-16" />,
|
||||
gradient: "from-amber-500 via-orange-500 to-red-500",
|
||||
features: [
|
||||
{
|
||||
icon: <Link2 className="h-5 w-5" />,
|
||||
text: "Proxy Support - Access ProxMenux through reverse proxies with full functionality",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "Authentication System - Secure your dashboard with password protection",
|
||||
},
|
||||
{
|
||||
icon: <Gauge className="h-5 w-5" />,
|
||||
text: "PCIe Link Speed Detection - View NVMe drive connection speeds and detect performance issues",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
text: "Enhanced Storage Display - Better formatting for disk sizes (auto-converts GB to TB when needed)",
|
||||
},
|
||||
{
|
||||
icon: <Network className="h-5 w-5" />,
|
||||
text: "SATA/SAS Information - View detailed interface information for all storage devices",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "System Overview",
|
||||
description:
|
||||
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
|
||||
@@ -48,7 +83,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: 3,
|
||||
title: "Storage Management",
|
||||
description:
|
||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
||||
@@ -57,7 +92,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
id: 4,
|
||||
title: "Network Metrics",
|
||||
description:
|
||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
||||
@@ -66,7 +101,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-teal-500 to-green-500",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
id: 5,
|
||||
title: "Virtual Machines & Containers",
|
||||
description:
|
||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
||||
@@ -75,7 +110,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
id: 6,
|
||||
title: "Hardware Information",
|
||||
description:
|
||||
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
|
||||
@@ -84,7 +119,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-emerald-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
id: 7,
|
||||
title: "System Logs",
|
||||
description:
|
||||
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
|
||||
@@ -93,7 +128,7 @@ const slides: OnboardingSlide[] = [
|
||||
gradient: "from-blue-500 to-indigo-500",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
id: 8,
|
||||
title: "Ready for the Future!",
|
||||
description:
|
||||
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
|
||||
@@ -106,6 +141,7 @@ export function OnboardingCarousel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem("proxmenux-onboarding-seen")
|
||||
@@ -119,6 +155,9 @@ export function OnboardingCarousel() {
|
||||
setDirection("next")
|
||||
setCurrentSlide(currentSlide + 1)
|
||||
} else {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
@@ -131,11 +170,16 @@ export function OnboardingCarousel() {
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDontShowAgain = () => {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -147,7 +191,7 @@ export function OnboardingCarousel() {
|
||||
const slide = slides[currentSlide]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||
{/* Close button */}
|
||||
@@ -155,7 +199,7 @@ export function OnboardingCarousel() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleSkip}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -197,14 +241,28 @@ export function OnboardingCarousel() {
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-8 space-y-4 md:space-y-6">
|
||||
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{slide.features && (
|
||||
<div className="space-y-2 md:space-y-3 py-2">
|
||||
{slide.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 md:gap-3 p-2 md:p-3 rounded-lg bg-muted/50 border border-border/50"
|
||||
>
|
||||
<div className="text-blue-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
|
||||
{slides.map((_, index) => (
|
||||
@@ -221,12 +279,12 @@ export function OnboardingCarousel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 md:gap-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 md:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handlePrev}
|
||||
disabled={currentSlide === 0}
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
className="gap-2 w-full sm:w-auto text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
@@ -235,10 +293,17 @@ export function OnboardingCarousel() {
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
{currentSlide < slides.length - 1 ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleSkip} className="flex-1 sm:flex-none bg-transparent">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSkip}
|
||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none">
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -246,7 +311,7 @@ export function OnboardingCarousel() {
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto"
|
||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
||||
>
|
||||
Get Started!
|
||||
<Sparkles className="h-4 w-4" />
|
||||
@@ -255,17 +320,19 @@ export function OnboardingCarousel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't show again */}
|
||||
{currentSlide === slides.length - 1 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleDontShowAgain}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2 pt-2 pb-1">
|
||||
<Checkbox
|
||||
id="dont-show-again"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="dont-show-again"
|
||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Don't show this again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import {
|
||||
RefreshCw,
|
||||
@@ -63,6 +64,7 @@ export function ProxmoxDashboard() {
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
|
||||
const fetchSystemData = useCallback(async () => {
|
||||
console.log("[v0] Fetching system data from Flask server...")
|
||||
@@ -244,7 +246,10 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="border-b border-border bg-card sticky top-0 z-50 shadow-sm">
|
||||
<header
|
||||
className="border-b border-border bg-card sticky top-0 z-50 shadow-sm cursor-pointer hover:bg-accent/5 transition-colors"
|
||||
onClick={() => setShowHealthModal(true)}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-4">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -299,7 +304,10 @@ export function ProxmoxDashboard() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
@@ -307,7 +315,9 @@ export function ProxmoxDashboard() {
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
@@ -317,11 +327,22 @@ export function ProxmoxDashboard() {
|
||||
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={refreshData} disabled={isRefreshing} className="h-8 w-8 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,6 +555,8 @@ export function ProxmoxDashboard() {
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Storage", href: "/storage", icon: HardDrive },
|
||||
{ name: "Network", href: "/network", icon: Network },
|
||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu },
|
||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { HardDrive, Database, Archive, AlertTriangle, CheckCircle, Activity, AlertCircle } from "lucide-react"
|
||||
import { formatStorage } from "@/lib/utils"
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
@@ -116,10 +117,10 @@ export function StorageMetrics() {
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.total.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.total)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{storageData.used.toFixed(1)} GB used • {storageData.available.toFixed(1)} GB available
|
||||
{formatStorage(storageData.used)} used • {formatStorage(storageData.available)} available
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -130,7 +131,7 @@ export function StorageMetrics() {
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.used.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.used)}</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">{usagePercent.toFixed(1)}% of total space</p>
|
||||
</CardContent>
|
||||
@@ -144,7 +145,7 @@ export function StorageMetrics() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.available.toFixed(1)} GB</div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{formatStorage(storageData.available)}</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{((storageData.available / storageData.total) * 100).toFixed(1)}% Free
|
||||
@@ -201,7 +202,7 @@ export function StorageMetrics() {
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{disk.used.toFixed(1)} GB / {disk.total.toFixed(1)} GB
|
||||
{formatStorage(disk.used)} / {formatStorage(disk.total)}
|
||||
</div>
|
||||
<Progress value={disk.usage_percent} className="w-24 mt-1" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Ther
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface DiskInfo {
|
||||
name: string
|
||||
@@ -75,12 +76,11 @@ const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else if (sizeInGB > 999) {
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(1)} TB`
|
||||
// Between 1 and 999 GB, show in GB
|
||||
return `${sizeInGB.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +93,9 @@ export function StorageOverview() {
|
||||
|
||||
const fetchStorageData = async () => {
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
|
||||
const [storageResponse, proxmoxResponse] = await Promise.all([
|
||||
fetch(`${baseUrl}/api/storage`),
|
||||
fetch(`${baseUrl}/api/proxmox-storage`),
|
||||
fetch(getApiUrl("/api/storage")),
|
||||
fetch(getApiUrl("/api/proxmox-storage")),
|
||||
])
|
||||
|
||||
const data = await storageResponse.json()
|
||||
@@ -107,6 +104,24 @@ export function StorageOverview() {
|
||||
console.log("[v0] Storage data received:", data)
|
||||
console.log("[v0] Proxmox storage data received:", proxmoxData)
|
||||
|
||||
if (proxmoxData && proxmoxData.storage) {
|
||||
const activeStorages = proxmoxData.storage.filter(
|
||||
(s: any) => s && s.total > 0 && s.used >= 0 && s.status?.toLowerCase() === "active",
|
||||
)
|
||||
console.log("[v0] Active storage volumes:", activeStorages.length)
|
||||
console.log(
|
||||
"[v0] Total used across all volumes (GB):",
|
||||
activeStorages.reduce((sum: number, s: any) => sum + s.used, 0),
|
||||
)
|
||||
|
||||
// Check for potential cluster node duplication
|
||||
const storageNames = activeStorages.map((s: any) => s.name)
|
||||
const uniqueNames = new Set(storageNames)
|
||||
if (storageNames.length !== uniqueNames.size) {
|
||||
console.warn("[v0] WARNING: Duplicate storage names detected - possible cluster node issue")
|
||||
}
|
||||
}
|
||||
|
||||
setStorageData(data)
|
||||
setProxmoxStorage(proxmoxData)
|
||||
} catch (error) {
|
||||
@@ -402,15 +417,22 @@ export function StorageOverview() {
|
||||
const diskHealthBreakdown = getDiskHealthBreakdown()
|
||||
const diskTypesBreakdown = getDiskTypesBreakdown()
|
||||
|
||||
// Only sum storage that belongs to the current node or filter appropriately
|
||||
const totalProxmoxUsed =
|
||||
proxmoxStorage && proxmoxStorage.storage
|
||||
? proxmoxStorage.storage
|
||||
.filter(
|
||||
(storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active",
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 && // Added check for valid used value
|
||||
storage.status &&
|
||||
storage.status.toLowerCase() === "active",
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.used, 0)
|
||||
: 0
|
||||
|
||||
// Convert storageData.total from TB to GB before calculating percentage
|
||||
const usagePercent =
|
||||
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
|
||||
|
||||
@@ -520,7 +542,14 @@ export function StorageOverview() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{proxmoxStorage.storage
|
||||
.filter((storage) => storage && storage.name && storage.total > 0)
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage &&
|
||||
storage.name &&
|
||||
storage.total > 0 &&
|
||||
storage.used >= 0 && // Ensure used is not negative
|
||||
storage.available >= 0, // Ensure available is not negative
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((storage) => (
|
||||
<div key={storage.name} className="border rounded-lg p-4">
|
||||
@@ -568,7 +597,7 @@ export function StorageOverview() {
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
<p className="font-medium">{storage.total.toLocaleString()} GB</p>
|
||||
<p className="font-medium">{formatStorage(storage.total)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Used</p>
|
||||
@@ -581,12 +610,12 @@ export function StorageOverview() {
|
||||
: "text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{storage.used.toLocaleString()} GB
|
||||
{formatStorage(storage.used)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Available</p>
|
||||
<p className="font-medium text-green-400">{storage.available.toLocaleString()} GB</p>
|
||||
<p className="font-medium text-green-400">{formatStorage(storage.available)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,12 +388,11 @@ export function SystemOverview() {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
} else if (sizeInGB > 999) {
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
} else {
|
||||
// Between 1 and 999 GB, show in GB
|
||||
return `${sizeInGB.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
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"
|
||||
import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { formatStorage } from "@/lib/utils" // Import formatStorage utility
|
||||
|
||||
interface VMData {
|
||||
vmid: number
|
||||
@@ -194,18 +195,18 @@ const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_in
|
||||
return "DHCP"
|
||||
}
|
||||
|
||||
const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
// const formatStorage = (sizeInGB: number): string => {
|
||||
// if (sizeInGB < 1) {
|
||||
// // Less than 1 GB, show in MB
|
||||
// return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
// } else if (sizeInGB < 1024) {
|
||||
// // Less than 1024 GB, show in GB
|
||||
// return `${sizeInGB.toFixed(1)} GB`
|
||||
// } else {
|
||||
// // 1024 GB or more, show in TB
|
||||
// return `${(sizeInGB / 1024).toFixed(1)} TB`
|
||||
// }
|
||||
// }
|
||||
|
||||
const getUsageColor = (percent: number): string => {
|
||||
if (percent >= 95) return "text-red-500"
|
||||
|
||||
@@ -11,21 +11,29 @@
|
||||
*/
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
console.log("[v0] getApiBaseUrl: Running on server (SSR)")
|
||||
return ""
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
console.log("[v0] getApiBaseUrl - protocol:", protocol, "hostname:", hostname, "port:", port)
|
||||
|
||||
// If accessing via standard ports (80/443) or no port, assume we're behind a proxy
|
||||
// In this case, use relative URLs so the proxy handles routing
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
|
||||
console.log("[v0] getApiBaseUrl - isStandardPort:", isStandardPort)
|
||||
|
||||
if (isStandardPort) {
|
||||
// Behind a proxy - use relative URL
|
||||
console.log("[v0] getApiBaseUrl: Detected proxy access, using relative URLs")
|
||||
return ""
|
||||
} else {
|
||||
// Direct access - use explicit port 8008
|
||||
return `${protocol}//${hostname}:8008`
|
||||
const baseUrl = `${protocol}//${hostname}:8008`
|
||||
console.log("[v0] getApiBaseUrl: Direct access detected, using:", baseUrl)
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,18 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatStorage(sizeInGB: number): string {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
const mb = sizeInGB * 1024
|
||||
return `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB % 1 === 0 ? sizeInGB.toFixed(0) : sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
const tb = sizeInGB / 1024
|
||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
echo "📋 Copying Flask server..."
|
||||
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
|
||||
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
|
||||
cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
@@ -279,6 +283,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
||||
flask-cors \
|
||||
psutil \
|
||||
requests \
|
||||
PyJWT \
|
||||
googletrans==4.0.0-rc1 \
|
||||
httpx==0.13.3 \
|
||||
httpcore==0.9.1 \
|
||||
@@ -321,10 +326,6 @@ echo "🔧 Installing hardware monitoring tools..."
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
|
||||
# ==============================================================
|
||||
|
||||
|
||||
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
|
||||
|
||||
dl_pkg() {
|
||||
@@ -361,21 +362,12 @@ dl_pkg() {
|
||||
return 1
|
||||
}
|
||||
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
|
||||
dl_pkg "ipmitool.deb" "ipmitool" || true
|
||||
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
|
||||
dl_pkg "lm-sensors.deb" "lm-sensors" || true
|
||||
dl_pkg "nut-client.deb" "nut-client" || true
|
||||
dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true
|
||||
|
||||
|
||||
# dl_pkg "nvidia-smi.deb" "nvidia-smi" "nvidia-utils" "nvidia-utils-535" "nvidia-utils-550" || true
|
||||
# dl_pkg "intel-gpu-tools.deb" "intel-gpu-tools" || true
|
||||
# dl_pkg "radeontop.deb" "radeontop" || true
|
||||
|
||||
echo "📦 Extracting .deb packages into AppDir..."
|
||||
extracted_count=0
|
||||
shopt -s nullglob
|
||||
@@ -395,7 +387,6 @@ else
|
||||
echo "✅ Extracted $extracted_count package(s)"
|
||||
fi
|
||||
|
||||
|
||||
if [ -d "$APP_DIR/bin" ]; then
|
||||
echo "📋 Normalizing /bin -> /usr/bin"
|
||||
mkdir -p "$APP_DIR/usr/bin"
|
||||
@@ -403,24 +394,20 @@ if [ -d "$APP_DIR/bin" ]; then
|
||||
rm -rf "$APP_DIR/bin"
|
||||
fi
|
||||
|
||||
|
||||
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
|
||||
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
|
||||
|
||||
|
||||
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
|
||||
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -x "$APP_DIR/usr/bin/ipmitool" ] && ldd "$APP_DIR/usr/bin/ipmitool" | grep -q 'not found'; then
|
||||
echo "❌ ipmitool has unresolved libs:"
|
||||
ldd "$APP_DIR/usr/bin/ipmitool" | grep 'not found' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -x "$APP_DIR/usr/bin/upsc" ] && ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
|
||||
echo "⚠️ upsc has unresolved libs, trying to auto-fix..."
|
||||
missing="$(ldd "$APP_DIR/usr/bin/upsc" | awk '/not found/{print $1}' | tr -d ' ')"
|
||||
@@ -463,12 +450,6 @@ echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
|
||||
[ -x "$APP_DIR/usr/bin/intel_gpu_top" ] && echo " • intel-gpu-tools: OK" || echo " • intel-gpu-tools: missing"
|
||||
[ -x "$APP_DIR/usr/bin/radeontop" ] && echo " • radeontop: OK" || echo " • radeontop: missing"
|
||||
|
||||
|
||||
|
||||
# ==============================================================
|
||||
|
||||
|
||||
|
||||
# Build AppImage
|
||||
echo "🔨 Building unified AppImage v${VERSION}..."
|
||||
cd "$WORK_DIR"
|
||||
|
||||
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 json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
@@ -22,10 +23,26 @@ import xml.etree.ElementTree as ET # Added for XML parsing
|
||||
import math # Imported math for format_bytes function
|
||||
import urllib.parse # Added for URL encoding
|
||||
import platform # Added for platform.release()
|
||||
import hashlib
|
||||
import secrets
|
||||
import jwt
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask_health_routes import health_bp
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from flask_auth_routes import auth_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for Next.js frontend
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
|
||||
|
||||
|
||||
def identify_gpu_type(name, vendor=None, bus=None, driver=None):
|
||||
"""
|
||||
Returns: 'Integrated' or 'PCI' (discrete)
|
||||
@@ -395,18 +412,18 @@ def get_intel_gpu_processes_from_text():
|
||||
processes.append(process_info)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
# print(f"[v0] Error parsing process line: {e}", flush=True)
|
||||
# print(f"[v0] Error parsing process line: {e}")
|
||||
pass
|
||||
continue
|
||||
break
|
||||
|
||||
if not header_found:
|
||||
# print(f"[v0] No process table found in intel_gpu_top output", flush=True)
|
||||
# print(f"[v0] No process table found in intel_gpu_top output")
|
||||
pass
|
||||
|
||||
return processes
|
||||
except Exception as e:
|
||||
# print(f"[v0] Error getting processes from intel_gpu_top text: {e}", flush=True)
|
||||
# print(f"[v0] Error getting processes from intel_gpu_top text: {e}")
|
||||
pass
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -917,6 +934,183 @@ def get_disk_hardware_info(disk_name):
|
||||
"""Placeholder for disk hardware info - to be populated by lsblk later."""
|
||||
return {}
|
||||
|
||||
def get_pcie_link_speed(disk_name):
|
||||
"""Get PCIe link speed information for NVMe drives"""
|
||||
pcie_info = {
|
||||
'pcie_gen': None,
|
||||
'pcie_width': None,
|
||||
'pcie_max_gen': None,
|
||||
'pcie_max_width': None
|
||||
}
|
||||
|
||||
try:
|
||||
# For NVMe drives, get PCIe information from sysfs
|
||||
if disk_name.startswith('nvme'):
|
||||
# Extract controller name properly using regex
|
||||
import re
|
||||
match = re.match(r'(nvme\d+)n\d+', disk_name)
|
||||
if not match:
|
||||
print(f"[v0] Could not extract controller from {disk_name}")
|
||||
return pcie_info
|
||||
|
||||
controller = match.group(1) # nvme0n1 -> nvme0
|
||||
print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
|
||||
|
||||
# Path to PCIe device in sysfs
|
||||
sys_path = f'/sys/class/nvme/{controller}/device'
|
||||
|
||||
print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
|
||||
|
||||
if os.path.exists(sys_path):
|
||||
try:
|
||||
pci_address = os.path.basename(os.readlink(sys_path))
|
||||
print(f"[v0] PCI address for {disk_name}: {pci_address}")
|
||||
|
||||
# Use lspci to get detailed PCIe information
|
||||
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
print(f"[v0] lspci output for {pci_address}:")
|
||||
for line in result.stdout.split('\n'):
|
||||
# Look for "LnkSta:" line which shows current link status
|
||||
if 'LnkSta:' in line:
|
||||
print(f"[v0] Found LnkSta: {line}")
|
||||
# Example: "LnkSta: Speed 8GT/s, Width x4"
|
||||
if 'Speed' in line:
|
||||
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
|
||||
if speed_match:
|
||||
gt_s = float(speed_match.group(1))
|
||||
if gt_s <= 2.5:
|
||||
pcie_info['pcie_gen'] = '1.0'
|
||||
elif gt_s <= 5.0:
|
||||
pcie_info['pcie_gen'] = '2.0'
|
||||
elif gt_s <= 8.0:
|
||||
pcie_info['pcie_gen'] = '3.0'
|
||||
elif gt_s <= 16.0:
|
||||
pcie_info['pcie_gen'] = '4.0'
|
||||
else:
|
||||
pcie_info['pcie_gen'] = '5.0'
|
||||
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
|
||||
|
||||
if 'Width' in line:
|
||||
width_match = re.search(r'Width\s+x(\d+)', line)
|
||||
if width_match:
|
||||
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
|
||||
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
|
||||
|
||||
# Look for "LnkCap:" line which shows maximum capabilities
|
||||
elif 'LnkCap:' in line:
|
||||
print(f"[v0] Found LnkCap: {line}")
|
||||
if 'Speed' in line:
|
||||
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
|
||||
if speed_match:
|
||||
gt_s = float(speed_match.group(1))
|
||||
if gt_s <= 2.5:
|
||||
pcie_info['pcie_max_gen'] = '1.0'
|
||||
elif gt_s <= 5.0:
|
||||
pcie_info['pcie_max_gen'] = '2.0'
|
||||
elif gt_s <= 8.0:
|
||||
pcie_info['pcie_max_gen'] = '3.0'
|
||||
elif gt_s <= 16.0:
|
||||
pcie_info['pcie_max_gen'] = '4.0'
|
||||
else:
|
||||
pcie_info['pcie_max_gen'] = '5.0'
|
||||
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
|
||||
|
||||
if 'Width' in line:
|
||||
width_match = re.search(r'Width\s+x(\d+)', line)
|
||||
if width_match:
|
||||
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
|
||||
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
|
||||
else:
|
||||
print(f"[v0] lspci failed with return code: {result.returncode}")
|
||||
except Exception as e:
|
||||
print(f"[v0] Error getting PCIe info via lspci: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f"[v0] sys_path does not exist: {sys_path}")
|
||||
alt_sys_path = f'/sys/block/{disk_name}/device/device'
|
||||
print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
|
||||
|
||||
if os.path.exists(alt_sys_path):
|
||||
try:
|
||||
# Get PCI address from the alternative path
|
||||
pci_address = os.path.basename(os.readlink(alt_sys_path))
|
||||
print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
|
||||
|
||||
# Use lspci to get detailed PCIe information
|
||||
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
print(f"[v0] lspci output for {pci_address} (from alt path):")
|
||||
for line in result.stdout.split('\n'):
|
||||
# Look for "LnkSta:" line which shows current link status
|
||||
if 'LnkSta:' in line:
|
||||
print(f"[v0] Found LnkSta: {line}")
|
||||
if 'Speed' in line:
|
||||
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
|
||||
if speed_match:
|
||||
gt_s = float(speed_match.group(1))
|
||||
if gt_s <= 2.5:
|
||||
pcie_info['pcie_gen'] = '1.0'
|
||||
elif gt_s <= 5.0:
|
||||
pcie_info['pcie_gen'] = '2.0'
|
||||
elif gt_s <= 8.0:
|
||||
pcie_info['pcie_gen'] = '3.0'
|
||||
elif gt_s <= 16.0:
|
||||
pcie_info['pcie_gen'] = '4.0'
|
||||
else:
|
||||
pcie_info['pcie_gen'] = '5.0'
|
||||
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
|
||||
|
||||
if 'Width' in line:
|
||||
width_match = re.search(r'Width\s+x(\d+)', line)
|
||||
if width_match:
|
||||
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
|
||||
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
|
||||
|
||||
# Look for "LnkCap:" line which shows maximum capabilities
|
||||
elif 'LnkCap:' in line:
|
||||
print(f"[v0] Found LnkCap: {line}")
|
||||
if 'Speed' in line:
|
||||
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
|
||||
if speed_match:
|
||||
gt_s = float(speed_match.group(1))
|
||||
if gt_s <= 2.5:
|
||||
pcie_info['pcie_max_gen'] = '1.0'
|
||||
elif gt_s <= 5.0:
|
||||
pcie_info['pcie_max_gen'] = '2.0'
|
||||
elif gt_s <= 8.0:
|
||||
pcie_info['pcie_max_gen'] = '3.0'
|
||||
elif gt_s <= 16.0:
|
||||
pcie_info['pcie_max_gen'] = '4.0'
|
||||
else:
|
||||
pcie_info['pcie_max_gen'] = '5.0'
|
||||
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
|
||||
|
||||
if 'Width' in line:
|
||||
width_match = re.search(r'Width\s+x(\d+)', line)
|
||||
if width_match:
|
||||
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
|
||||
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
|
||||
else:
|
||||
print(f"[v0] lspci failed with return code: {result.returncode}")
|
||||
except Exception as e:
|
||||
print(f"[v0] Error getting PCIe info from alt path: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
|
||||
return pcie_info
|
||||
|
||||
# get_pcie_link_speed function definition ends here
|
||||
|
||||
def get_smart_data(disk_name):
|
||||
"""Get SMART data for a specific disk - Enhanced with multiple device type attempts"""
|
||||
smart_data = {
|
||||
@@ -947,8 +1141,8 @@ def get_smart_data(disk_name):
|
||||
try:
|
||||
commands_to_try = [
|
||||
['smartctl', '-a', '-j', f'/dev/{disk_name}'], # JSON output (preferred)
|
||||
['smartctl', '-a', '-j', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type
|
||||
['smartctl', '-a', '-j', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
|
||||
['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # JSON with ATA device type
|
||||
['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # JSON with SAT device type
|
||||
['smartctl', '-a', f'/dev/{disk_name}'], # Text output (fallback)
|
||||
['smartctl', '-a', '-d', 'ata', f'/dev/{disk_name}'], # Text with ATA device type
|
||||
['smartctl', '-a', '-d', 'sat', f'/dev/{disk_name}'], # Text with SAT device type
|
||||
@@ -2013,7 +2207,7 @@ def get_proxmox_vms():
|
||||
# print(f"[v0] Error getting VM/LXC info: {e}")
|
||||
pass
|
||||
return {
|
||||
'error': f'Unable to access VM information: {str(e)}',
|
||||
'error': 'Unable to access VM information: {str(e)}',
|
||||
'vms': []
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -2548,7 +2742,7 @@ def get_detailed_gpu_info(gpu):
|
||||
if 'clients' in json_data:
|
||||
client_count = len(json_data['clients'])
|
||||
|
||||
for client_id, client_data in json_data['clients'].items():
|
||||
for client_id, client_data in json_data['clients']:
|
||||
client_name = client_data.get('name', 'Unknown')
|
||||
client_pid = client_data.get('pid', 'Unknown')
|
||||
|
||||
@@ -2630,7 +2824,7 @@ def get_detailed_gpu_info(gpu):
|
||||
clients = best_json['clients']
|
||||
processes = []
|
||||
|
||||
for client_id, client_data in clients.items():
|
||||
for client_id, client_data in clients:
|
||||
process_info = {
|
||||
'name': client_data.get('name', 'Unknown'),
|
||||
'pid': client_data.get('pid', 'Unknown'),
|
||||
@@ -3091,22 +3285,22 @@ def get_detailed_gpu_info(gpu):
|
||||
# print(f"[v0] Temperature: {detailed_info['temperature']}°C", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
|
||||
# Parse power draw (GFX Power or average_socket_power)
|
||||
if 'GFX Power' in sensors:
|
||||
gfx_power = sensors['GFX Power']
|
||||
if 'value' in gfx_power:
|
||||
detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W"
|
||||
# print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
elif 'average_socket_power' in sensors:
|
||||
socket_power = sensors['average_socket_power']
|
||||
if 'value' in socket_power:
|
||||
detailed_info['power_draw'] = f"{socket_power['value']:.2f} W"
|
||||
# print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
|
||||
# Parse power draw (GFX Power or average_socket_power)
|
||||
if 'GFX Power' in sensors:
|
||||
gfx_power = sensors['GFX Power']
|
||||
if 'value' in gfx_power:
|
||||
detailed_info['power_draw'] = f"{gfx_power['value']:.2f} W"
|
||||
# print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
elif 'average_socket_power' in sensors:
|
||||
socket_power = sensors['average_socket_power']
|
||||
if 'value' in socket_power:
|
||||
detailed_info['power_draw'] = f"{socket_power['value']:.2f} W"
|
||||
# print(f"[v0] Power Draw: {detailed_info['power_draw']}", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
|
||||
# Parse clocks (GFX_SCLK for graphics, GFX_MCLK for memory)
|
||||
if 'Clocks' in device:
|
||||
@@ -3115,7 +3309,7 @@ def get_detailed_gpu_info(gpu):
|
||||
gfx_clock = clocks['GFX_SCLK']
|
||||
if 'value' in gfx_clock:
|
||||
detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz"
|
||||
# print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']}", flush=True)
|
||||
# print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']} MHz", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
|
||||
@@ -3123,7 +3317,7 @@ def get_detailed_gpu_info(gpu):
|
||||
mem_clock = clocks['GFX_MCLK']
|
||||
if 'value' in mem_clock:
|
||||
detailed_info['clock_memory'] = f"{mem_clock['value']} MHz"
|
||||
# print(f"[v0] Memory Clock: {detailed_info['clock_memory']}", flush=True)
|
||||
# print(f"[v0] Memory Clock: {detailed_info['clock_memory']} MHz", flush=True)
|
||||
pass
|
||||
data_retrieved = True
|
||||
|
||||
@@ -3360,7 +3554,6 @@ def get_detailed_gpu_info(gpu):
|
||||
else:
|
||||
# print(f"[v0] No fdinfo section found in device data", flush=True)
|
||||
pass
|
||||
detailed_info['processes'] = []
|
||||
|
||||
if data_retrieved:
|
||||
detailed_info['has_monitoring_tool'] = True
|
||||
@@ -3886,6 +4079,10 @@ def get_hardware_info():
|
||||
except:
|
||||
pass
|
||||
|
||||
pcie_info = {}
|
||||
if disk_name.startswith('nvme'):
|
||||
pcie_info = get_pcie_link_speed(disk_name)
|
||||
|
||||
# Build storage device with all available information
|
||||
storage_device = {
|
||||
'name': disk_name,
|
||||
@@ -3901,6 +4098,9 @@ def get_hardware_info():
|
||||
'sata_version': sata_version,
|
||||
}
|
||||
|
||||
if pcie_info:
|
||||
storage_device.update(pcie_info)
|
||||
|
||||
# Add family if available (from smartctl)
|
||||
try:
|
||||
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
|
||||
@@ -3922,7 +4122,7 @@ def get_hardware_info():
|
||||
# print(f"[v0] Error getting storage info: {e}")
|
||||
pass
|
||||
|
||||
# Graphics Cards (from lspci - will be duplicated by new PCI device listing, but kept for now)
|
||||
# Graphics Cards
|
||||
try:
|
||||
# Try nvidia-smi first
|
||||
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'],
|
||||
@@ -4467,7 +4667,7 @@ def api_network_interface_metrics(interface_name):
|
||||
for point in all_data:
|
||||
filtered_point = {'time': point.get('time')}
|
||||
# Add network fields if they exist
|
||||
for key in ['netin', 'netout', 'diskread', 'diskwrite']:
|
||||
for key in ['netin', 'netout']:
|
||||
if key in point:
|
||||
filtered_point[key] = point[key]
|
||||
rrd_data.append(filtered_point)
|
||||
@@ -5379,10 +5579,6 @@ def api_prometheus():
|
||||
mem_used_bytes = mem_used * 1024 * 1024 # Convert MiB to bytes
|
||||
mem_total_bytes = mem_total * 1024 * 1024
|
||||
|
||||
metrics.append(f'# HELP proxmox_gpu_memory_used_bytes GPU memory used in bytes')
|
||||
metrics.append(f'# TYPE proxmox_gpu_memory_used_bytes gauge')
|
||||
metrics.append(f'proxmox_gpu_memory_used_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_used_bytes} {timestamp}')
|
||||
|
||||
metrics.append(f'# HELP proxmox_gpu_memory_total_bytes GPU memory total in bytes')
|
||||
metrics.append(f'# TYPE proxmox_gpu_memory_total_bytes gauge')
|
||||
metrics.append(f'proxmox_gpu_memory_total_bytes{{node="{node}",gpu="{gpu_name}",vendor="{gpu_vendor}",slot="{gpu_slot}"}} {mem_total_bytes} {timestamp}')
|
||||
@@ -5475,7 +5671,7 @@ def api_system_info():
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to get node info from Proxmox API
|
||||
# Try to get node info from Proxmox API
|
||||
try:
|
||||
result = subprocess.run(['pvesh', 'get', '/nodes', '--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
|
||||
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
|
||||
form_factor?: string
|
||||
sata_version?: string
|
||||
pcie_gen?: string // e.g., "PCIe 4.0"
|
||||
pcie_width?: string // e.g., "x4"
|
||||
pcie_max_gen?: string // Maximum supported PCIe generation
|
||||
pcie_max_width?: string // Maximum supported PCIe lanes
|
||||
sas_version?: string // e.g., "SAS-3"
|
||||
sas_speed?: string // e.g., "12Gb/s"
|
||||
link_speed?: string // Generic link speed info
|
||||
}
|
||||
|
||||
export interface PCIDevice {
|
||||
|
||||
Reference in New Issue
Block a user