Merge branch 'MacRimi:main' into main

This commit is contained in:
cod378
2025-11-07 21:53:57 -03:00
committed by GitHub
32 changed files with 1908 additions and 909 deletions

View File

@@ -1,29 +1,29 @@
---
name: Bug Report
about: Reporta un problema en el proyecto
title: "[BUG] Describe el problema"
about: Report a problem in the project
title: "[BUG] Describe the issue"
labels: bug
assignees: 'MacRimi'
---
## Descripción
Describe el error de forma clara y concisa.
## Description
Describe the bug clearly and concisely.
## Pasos para reproducir
## Steps to Reproduce
1. ...
2. ...
3. ...
## Comportamiento esperado
¿Qué debería ocurrir?
## Expected Behavior
What should happen?
## Capturas de pantalla (Obligatorio)
Agrega imágenes para ayudar a entender el problema.
## Screenshots (Required)
Add images to help illustrate the issue.
## Entorno
- Sistema operativo:
- Versión del software:
- Otros detalles relevantes:
## Environment
- Operating system:
- Software version:
- Other relevant details:
## Información adicional
Agrega cualquier otro contexto sobre el problema aquí.
## Additional Information
Add any other context about the problem here.

View File

@@ -2,4 +2,4 @@ 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.
about: If your request is neither a bug nor a feature, please use Discussions.

View File

@@ -1,19 +1,19 @@
---
name: Feature Request
about: Sugiere una nueva funcionalidad o mejora
title: "[FEATURE] Describe la propuesta"
about: Suggest a new feature or improvement
title: "[FEATURE] Describe your proposal"
labels: enhancement
assignees: 'MacRimi'
---
## Descripción
Explica la funcionalidad que propones.
## Description
Explain the feature you are proposing.
## Motivación
¿Por qué es importante esta mejora? ¿Qué problema resuelve?
## Motivation
Why is this improvement important? What problem does it solve?
## Alternativas consideradas
¿Hay otras soluciones que hayas pensado?
## Alternatives Considered
Are there other solutions you have thought about?
## Información adicional
Agrega cualquier detalle extra que ayude a entender la propuesta.
## Additional Information
Add any extra details that help understand your proposal.

View File

@@ -1,7 +1,85 @@
"use client"
import { useState, useEffect } from "react"
import { ProxmoxDashboard } from "../components/proxmox-dashboard"
import { Login } from "../components/login"
import { AuthSetup } from "../components/auth-setup"
import { getApiUrl } from "../lib/api-config"
export default function Home() {
return <ProxmoxDashboard />
const [authStatus, setAuthStatus] = useState<{
loading: boolean
authEnabled: boolean
authConfigured: boolean
authenticated: boolean
}>({
loading: true,
authEnabled: false,
authConfigured: false,
authenticated: false,
})
useEffect(() => {
checkAuthStatus()
}, [])
const checkAuthStatus = async () => {
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/status"), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const data = await response.json()
console.log("[v0] Auth status:", data)
const authenticated = data.auth_enabled ? data.authenticated : true
setAuthStatus({
loading: false,
authEnabled: data.auth_enabled,
authConfigured: data.auth_configured,
authenticated,
})
} catch (error) {
console.error("[v0] Failed to check auth status:", error)
setAuthStatus({
loading: false,
authEnabled: false,
authConfigured: false,
authenticated: true,
})
}
}
const handleAuthComplete = () => {
checkAuthStatus()
}
const handleLoginSuccess = () => {
checkAuthStatus()
}
if (authStatus.loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
)
}
if (authStatus.authEnabled && !authStatus.authenticated) {
return <Login onLogin={handleLoginSuccess} />
}
// Show dashboard in all other cases
return (
<>
{!authStatus.authConfigured && <AuthSetup onComplete={handleAuthComplete} />}
<ProxmoxDashboard />
</>
)
}

View File

@@ -129,15 +129,15 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
{step === "choice" ? (
<div className="space-y-6">
<div className="space-y-6 py-2">
<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">
<p className="text-muted-foreground text-sm">
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
</p>
</div>
@@ -161,13 +161,13 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<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="space-y-6 py-2">
<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>
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
</div>
{error && (
@@ -179,7 +179,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username" className="text-sm">
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
@@ -188,14 +190,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Enter username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="pl-10"
className="pl-10 text-base"
disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password" className="text-sm">
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
@@ -204,14 +209,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10"
className="pl-10 text-base"
disabled={loading}
autoComplete="new-password"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Label htmlFor="confirm-password" className="text-sm">
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
@@ -220,8 +228,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10"
className="pl-10 text-base"
disabled={loading}
autoComplete="new-password"
/>
</div>
</div>

View File

@@ -163,14 +163,49 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
}
export default function Hardware() {
// Static data - load once without refresh
const {
data: hardwareData,
error,
isLoading,
data: staticHardwareData,
error: staticError,
isLoading: staticLoading,
} = useSWR<HardwareData>("/api/hardware", fetcher, {
refreshInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 0, // No auto-refresh for static data
})
// Dynamic data - refresh every 5 seconds for temperatures, fans, power, ups
const {
data: dynamicHardwareData,
error: dynamicError,
isLoading: dynamicLoading,
} = useSWR<HardwareData>("/api/hardware", fetcher, {
refreshInterval: 7000,
})
// Merge static and dynamic data, preferring static for CPU/memory/PCI/disks
const hardwareData = staticHardwareData
? {
...dynamicHardwareData,
// Keep static data from initial load
cpu: staticHardwareData.cpu,
motherboard: staticHardwareData.motherboard,
memory_modules: staticHardwareData.memory_modules,
pci_devices: staticHardwareData.pci_devices,
storage_devices: staticHardwareData.storage_devices,
gpus: staticHardwareData.gpus,
// Use dynamic data for these
temperatures: dynamicHardwareData?.temperatures,
fans: dynamicHardwareData?.fans,
power_meter: dynamicHardwareData?.power_meter,
power_supplies: dynamicHardwareData?.power_supplies,
ups: dynamicHardwareData?.ups,
}
: undefined
const error = staticError || dynamicError
const isLoading = staticLoading
useEffect(() => {
if (hardwareData?.storage_devices) {
console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)

View File

@@ -2,11 +2,12 @@
import type React from "react"
import { useState } from "react"
import { useState, useEffect } 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 { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
@@ -17,9 +18,23 @@ interface LoginProps {
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
const savedUsername = localStorage.getItem("proxmenux-saved-username")
const savedPassword = localStorage.getItem("proxmenux-saved-password")
if (savedUsername && savedPassword) {
setUsername(savedUsername)
setPassword(savedPassword)
setRememberMe(true)
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
@@ -29,23 +44,46 @@ export function Login({ onLogin }: LoginProps) {
return
}
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
return
}
setLoading(true)
try {
const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({
username,
password,
totp_token: totpCode || undefined, // Include 2FA code if provided
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Login failed")
if (data.requires_totp) {
setRequiresTotp(true)
setLoading(false)
return
}
if (!response.ok) {
throw new Error(data.message || "Login failed")
}
// Save token
localStorage.setItem("proxmenux-auth-token", data.token)
if (rememberMe) {
localStorage.setItem("proxmenux-saved-username", username)
localStorage.setItem("proxmenux-saved-password", password)
} else {
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
}
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
@@ -94,42 +132,107 @@ export function Login({ onLogin }: LoginProps) {
</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>
{!requiresTotp ? (
<>
<div className="space-y-2">
<Label htmlFor="login-username" className="text-sm">
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 text-base"
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 className="space-y-2">
<Label htmlFor="login-password" className="text-sm">
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 text-base"
disabled={loading}
autoComplete="current-password"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
</Label>
</div>
</>
) : (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code" className="text-sm">
Authentication Code
</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={6}
disabled={loading}
autoComplete="one-time-code"
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
You can also use a backup code (format: XXXX-XXXX)
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRequiresTotp(false)
setTotpCode("")
setError("")
}}
className="w-full"
>
Back to login
</Button>
</div>
</div>
)}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
</Button>
</form>
</div>

View File

@@ -3,7 +3,7 @@
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react"
import useSWR from "swr"
import { NetworkTrafficChart } from "./network-traffic-chart"
@@ -149,7 +149,7 @@ export function NetworkMetrics() {
error,
isLoading,
} = useSWR<NetworkData>("/api/network", fetcher, {
refreshInterval: 60000, // Refresh every 60 seconds
refreshInterval: 53000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
})
@@ -161,13 +161,13 @@ export function NetworkMetrics() {
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const { data: modalNetworkData } = useSWR<NetworkData>(selectedInterface ? "/api/network" : null, fetcher, {
refreshInterval: 15000, // Refresh every 15 seconds when modal is open
refreshInterval: 17000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
})
const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, {
refreshInterval: 30000,
refreshInterval: 29000,
revalidateOnFocus: false,
})
@@ -688,6 +688,9 @@ export function NetworkMetrics() {
<Router className="h-5 w-5" />
{selectedInterface?.name} - Interface Details
</DialogTitle>
<DialogDescription>
View detailed information and network traffic statistics for this interface
</DialogDescription>
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
<div className="flex justify-end pt-2">
<Select value={modalTimeframe} onValueChange={(value: any) => setModalTimeframe(value)}>

View File

@@ -10,6 +10,7 @@ import { NetworkMetrics } from "./network-metrics"
import { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { getApiUrl } from "../lib/api-config"
@@ -26,6 +27,7 @@ import {
Box,
Cpu,
FileText,
SettingsIcon,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -49,11 +51,18 @@ interface FlaskSystemData {
load_average: number[]
}
interface FlaskSystemInfo {
hostname: string
node_id: string
uptime: string
health_status: "healthy" | "warning" | "critical"
}
export function ProxmoxDashboard() {
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
lastUpdate: new Date().toLocaleTimeString(),
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: "Loading...",
nodeId: "Loading...",
})
@@ -67,12 +76,7 @@ export function ProxmoxDashboard() {
const [showHealthModal, setShowHealthModal] = useState(false)
const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...")
console.log("[v0] Current window location:", window.location.href)
const apiUrl = getApiUrl("/api/system")
console.log("[v0] API URL:", apiUrl)
const apiUrl = getApiUrl("/api/system-info")
try {
const response = await fetch(apiUrl, {
@@ -82,37 +86,26 @@ export function ProxmoxDashboard() {
},
cache: "no-store",
})
console.log("[v0] Response status:", response.status)
if (!response.ok) {
throw new Error(`Server responded with status: ${response.status}`)
}
const data: FlaskSystemData = await response.json()
console.log("[v0] System data received:", data)
const data: FlaskSystemInfo = await response.json()
let status: "healthy" | "warning" | "critical" = "healthy"
if (data.cpu_usage > 90 || data.memory_usage > 90) {
status = "critical"
} else if (data.cpu_usage > 75 || data.memory_usage > 75) {
status = "warning"
}
const uptimeValue =
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
setSystemStatus({
status,
uptime: data.uptime,
lastUpdate: new Date().toLocaleTimeString(),
serverName: data.hostname,
nodeId: data.node_id,
status: data.health_status || "healthy",
uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname || "Unknown",
nodeId: data.node_id || "Unknown",
})
setIsServerConnected(true)
} catch (error) {
console.error("[v0] Failed to fetch system data from Flask server:", error)
console.error("[v0] Error details:", {
message: error instanceof Error ? error.message : "Unknown error",
apiUrl,
windowLocation: window.location.href,
})
setIsServerConnected(false)
setSystemStatus((prev) => ({
@@ -121,16 +114,26 @@ export function ProxmoxDashboard() {
serverName: "Server Offline",
nodeId: "Server Offline",
uptime: "N/A",
lastUpdate: new Date().toLocaleTimeString(),
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
}))
}
}, [])
useEffect(() => {
// Siempre fetch inicial
fetchSystemData()
const interval = setInterval(fetchSystemData, 10000)
return () => clearInterval(interval)
}, [fetchSystemData])
let interval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
interval = setInterval(fetchSystemData, 9000) // Cambiado de 10000 a 9000ms
} else {
interval = setInterval(fetchSystemData, 61000) // Cambiado de 60000 a 61000ms
}
return () => {
if (interval) clearInterval(interval)
}
}, [fetchSystemData, activeTab])
useEffect(() => {
if (
@@ -216,6 +219,8 @@ export function ProxmoxDashboard() {
return "Hardware"
case "logs":
return "System Logs"
case "settings":
return "Settings"
default:
return "Navigation Menu"
}
@@ -299,7 +304,9 @@ export function ProxmoxDashboard() {
<span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge>
<div className="text-sm text-muted-foreground whitespace-nowrap">Uptime: {systemStatus.uptime}</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
Uptime: {systemStatus.uptime || "N/A"}
</div>
<Button
variant="outline"
@@ -348,7 +355,7 @@ export function ProxmoxDashboard() {
{/* Mobile Server Info */}
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime}</span>
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime || "N/A"}</span>
</div>
</div>
</header>
@@ -362,7 +369,7 @@ export function ProxmoxDashboard() {
>
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
<TabsList className="hidden md:grid w-full grid-cols-6 bg-card border border-border">
<TabsList className="hidden md:grid w-full grid-cols-7 bg-card border border-border">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
@@ -399,6 +406,12 @@ export function ProxmoxDashboard() {
>
System Logs
</TabsTrigger>
<TabsTrigger
value="settings"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Settings
</TabsTrigger>
</TabsList>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
@@ -507,6 +520,21 @@ export function ProxmoxDashboard() {
<FileText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("settings")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "settings"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</Button>
</div>
</SheetContent>
</Sheet>
@@ -539,10 +567,14 @@ export function ProxmoxDashboard() {
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
<Settings />
</TabsContent>
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
<p className="font-medium mb-2">ProxMenux Monitor v1.0.0</p>
<p className="font-medium mb-2">ProxMenux Monitor v1.0.1</p>
<p>
<a
href="https://ko-fi.com/macrimi"

View File

@@ -5,11 +5,13 @@ 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 { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
@@ -26,6 +28,10 @@ export function Settings() {
const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("")
useEffect(() => {
checkAuthStatus()
}, [])
@@ -35,6 +41,7 @@ export function Settings() {
const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json()
setAuthEnabled(data.auth_enabled || false)
setTotpEnabled(data.totp_enabled || false) // Get 2FA status
} catch (err) {
console.error("Failed to check auth status:", err)
}
@@ -109,19 +116,31 @@ export function Settings() {
setSuccess("")
try {
const response = await fetch(getApiUrl("/api/auth/setup"), {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/disable"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_auth: false }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) throw new Error("Failed to disable authentication")
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to disable authentication")
}
localStorage.removeItem("proxmenux-auth-token")
setSuccess("Authentication disabled successfully!")
setAuthEnabled(false)
localStorage.removeItem("proxmenux-auth-setup-complete")
setSuccess("Authentication disabled successfully! Reloading...")
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err) {
setError("Failed to disable authentication. Please try again.")
setError(err instanceof Error ? err.message : "Failed to disable authentication. Please try again.")
} finally {
setLoading(false)
}
@@ -184,8 +203,49 @@ export function Settings() {
}
}
const handleDisable2FA = async () => {
setError("")
setSuccess("")
if (!disable2FAPassword) {
setError("Please enter your password")
return
}
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/disable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password: disable2FAPassword }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to disable 2FA")
}
setSuccess("2FA disabled successfully!")
setTotpEnabled(false)
setShow2FADisable(false)
setDisable2FAPassword("")
checkAuthStatus()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disable 2FA")
} finally {
setLoading(false)
}
}
const handleLogout = () => {
localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete")
window.location.reload()
}
@@ -322,6 +382,7 @@ export function Settings() {
{authEnabled && (
<div className="space-y-3">
<Button onClick={handleLogout} variant="outline" className="w-full bg-transparent">
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
@@ -404,6 +465,74 @@ export function Settings() {
</div>
)}
{!totpEnabled && (
<Button
onClick={() => setShow2FASetup(true)}
variant="outline"
className="w-full bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/20"
>
<Shield className="h-4 w-4 mr-2" />
Enable Two-Factor Authentication
</Button>
)}
{totpEnabled && (
<div className="space-y-3">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3 flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<p className="text-sm text-green-500 font-medium">2FA is enabled</p>
</div>
{!show2FADisable && (
<Button onClick={() => setShow2FADisable(true)} variant="outline" className="w-full">
<Shield className="h-4 w-4 mr-2" />
Disable 2FA
</Button>
)}
{show2FADisable && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Disable Two-Factor Authentication</h3>
<p className="text-sm text-muted-foreground">Enter your password to confirm</p>
<div className="space-y-2">
<Label htmlFor="disable-2fa-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="disable-2fa-password"
type="password"
placeholder="Enter your password"
value={disable2FAPassword}
onChange={(e) => setDisable2FAPassword(e.target.value)}
className="pl-10"
disabled={loading}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleDisable2FA} variant="destructive" className="flex-1" disabled={loading}>
{loading ? "Disabling..." : "Disable 2FA"}
</Button>
<Button
onClick={() => {
setShow2FADisable(false)
setDisable2FAPassword("")
setError("")
}}
variant="outline"
className="flex-1"
disabled={loading}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
<Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
Disable Authentication
</Button>
@@ -429,6 +558,15 @@ export function Settings() {
</div>
</CardContent>
</Card>
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
onSuccess={() => {
setSuccess("2FA enabled successfully!")
checkAuthStatus()
}}
/>
</div>
)
}

View File

@@ -27,7 +27,7 @@ import {
Menu,
Terminal,
} from "lucide-react"
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
interface Log {
timestamp: string
@@ -428,39 +428,61 @@ export function SystemLogs() {
}
}
const logsOnly: CombinedLogEntry[] = logs
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const safeToLowerCase = (value: any): string => {
if (value === null || value === undefined) return ""
return String(value).toLowerCase()
}
const eventsOnly: CombinedLogEntry[] = events
.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
}))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const memoizedLogs = useMemo(() => logs, [logs])
const memoizedEvents = useMemo(() => events, [events])
const memoizedBackups = useMemo(() => backups, [backups])
const memoizedNotifications = useMemo(() => notifications, [notifications])
const logsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedLogs
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs],
)
const eventsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedEvents
.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
}))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedEvents],
)
// Filter logs only
const filteredLogsOnly = logsOnly.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.service.toLowerCase().includes(searchTerm.toLowerCase())
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
return matchesSearch && matchesLevel && matchesService
})
// Filter events only
const filteredEventsOnly = eventsOnly.filter((event) => {
const message = event.message || ""
const service = event.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
event.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.service.toLowerCase().includes(searchTerm.toLowerCase())
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || event.level === levelFilter
const matchesService = serviceFilter === "all" || event.service === serviceFilter
@@ -470,30 +492,40 @@ export function SystemLogs() {
const displayedLogsOnly = filteredLogsOnly.slice(0, displayedLogsCount)
const displayedEventsOnly = filteredEventsOnly.slice(0, displayedLogsCount)
const combinedLogs: CombinedLogEntry[] = [
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...events.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
})),
].sort((a, b) => b.sortTimestamp - a.sortTimestamp) // Sort by timestamp descending
const combinedLogs: CombinedLogEntry[] = useMemo(
() =>
[
...memoizedLogs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...memoizedEvents.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
})),
].sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs, memoizedEvents],
)
// Filter combined logs
const filteredCombinedLogs = combinedLogs.filter((log) => {
const matchesSearch =
log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.service.toLowerCase().includes(searchTerm.toLowerCase())
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
const filteredCombinedLogs = useMemo(
() =>
combinedLogs.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
return matchesSearch && matchesLevel && matchesService
})
const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
return matchesSearch && matchesLevel && matchesService
}),
[combinedLogs, searchTerm, levelFilter, serviceFilter],
)
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
@@ -555,7 +587,9 @@ export function SystemLogs() {
}
const getNotificationTypeColor = (type: string) => {
switch (type.toLowerCase()) {
if (!type) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
switch (safeToLowerCase(type)) {
case "error":
return "bg-red-500/10 text-red-500 border-red-500/20"
case "warning":
@@ -571,7 +605,9 @@ export function SystemLogs() {
// ADDED: New function for notification source colors
const getNotificationSourceColor = (source: string) => {
switch (source.toLowerCase()) {
if (!source) return "bg-gray-500/10 text-gray-500 border-gray-500/20"
switch (safeToLowerCase(source)) {
case "task-log":
return "bg-purple-500/10 text-purple-500 border-purple-500/20"
case "journal":
@@ -590,7 +626,7 @@ export function SystemLogs() {
info: logs.filter((log) => ["info", "notice", "debug"].includes(log.level)).length,
}
const uniqueServices = [...new Set(logs.map((log) => log.service))]
const uniqueServices = useMemo(() => [...new Set(memoizedLogs.map((log) => log.service))], [memoizedLogs])
const getBackupType = (volid: string): "vm" | "lxc" => {
if (volid.includes("/vm/") || volid.includes("vzdump-qemu")) {
@@ -915,9 +951,11 @@ export function SystemLogs() {
<SelectValue placeholder="Filter by service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
{uniqueServices.slice(0, 20).map((service) => (
<SelectItem key={service} value={service}>
<SelectItem key="service-all" value="all">
All Services
</SelectItem>
{uniqueServices.slice(0, 20).map((service, idx) => (
<SelectItem key={`service-${service}-${idx}`} value={service}>
{service}
</SelectItem>
))}
@@ -932,51 +970,59 @@ export function SystemLogs() {
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-x-hidden">
<div className="space-y-2 p-4 w-full box-border">
{displayedLogs.map((log, index) => (
<div
key={index}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
onClick={() => {
if (log.eventData) {
setSelectedEvent(log.eventData)
setIsEventModalOpen(true)
} else {
setSelectedLog(log as SystemLog)
setIsLogModalOpen(true)
}
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
</Badge>
)}
</div>
{displayedLogs.map((log, index) => {
// Generate a more stable unique key
const timestampMs = new Date(log.timestamp).getTime()
const uniqueKey = log.eventData
? `event-${log.eventData.upid.replace(/:/g, "-")}-${timestampMs}`
: `log-${timestampMs}-${log.service?.substring(0, 10) || "unknown"}-${log.pid || "nopid"}-${index}`
<div className="flex-1 min-w-0 overflow-hidden box-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
{log.timestamp}
return (
<div
key={uniqueKey}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
onClick={() => {
if (log.eventData) {
setSelectedEvent(log.eventData)
setIsEventModalOpen(true)
} else {
setSelectedLog(log as SystemLog)
setIsLogModalOpen(true)
}
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
</Badge>
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden box-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
{log.timestamp}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{log.message}
</div>
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
{log.source}
{log.pid && ` • PID: ${log.pid}`}
{log.hostname && ` • Host: ${log.hostname}`}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{log.message}
</div>
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
{log.source}
{log.pid && ` • PID: ${log.pid}`}
{log.hostname && ` • Host: ${log.hostname}`}
</div>
</div>
</div>
))}
)
})}
{displayedLogs.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
@@ -1037,44 +1083,48 @@ export function SystemLogs() {
<ScrollArea className="h-[500px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{backups.map((backup, index) => (
<div
key={index}
className="flex items-start space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => {
setSelectedBackup(backup)
setIsBackupModalOpen(true)
}}
>
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-500" />
</div>
{memoizedBackups.map((backup, index) => {
const uniqueKey = `backup-${backup.volid.replace(/[/:]/g, "-")}-${backup.timestamp || index}`
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
{getBackupTypeLabel(backup.volid)}
</Badge>
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
{getBackupStorageLabel(backup.volid)}
return (
<div
key={uniqueKey}
className="flex items-start space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => {
setSelectedBackup(backup)
setIsBackupModalOpen(true)
}}
>
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
{getBackupTypeLabel(backup.volid)}
</Badge>
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
{getBackupStorageLabel(backup.volid)}
</Badge>
</div>
<Badge
variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
>
{backup.size_human}
</Badge>
</div>
<Badge
variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
>
{backup.size_human}
</Badge>
</div>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
</div>
</div>
</div>
</div>
))}
)
})}
{backups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
@@ -1090,42 +1140,47 @@ export function SystemLogs() {
<TabsContent value="notifications" className="space-y-4">
<ScrollArea className="h-[600px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{notifications.map((notification, index) => (
<div
key={index}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full"
onClick={() => {
setSelectedNotification(notification)
setIsNotificationModalOpen(true)
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{notification.type.toUpperCase()}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{notification.source.toUpperCase()}
</Badge>
</div>
{memoizedNotifications.map((notification, index) => {
const timestampMs = new Date(notification.timestamp).getTime()
const uniqueKey = `notification-${timestampMs}-${notification.service?.substring(0, 10) || "unknown"}-${notification.source?.substring(0, 10) || "unknown"}-${index}`
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate">{notification.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{notification.timestamp}
return (
<div
key={uniqueKey}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full"
onClick={() => {
setSelectedNotification(notification)
setIsNotificationModalOpen(true)
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{notification.type.toUpperCase()}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{notification.source.toUpperCase()}
</Badge>
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate">{notification.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{notification.timestamp}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{notification.message}
</div>
<div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{notification.message}
</div>
<div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source}
</div>
</div>
</div>
))}
)
})}
{notifications.length === 0 && (
<div className="text-center py-8 text-muted-foreground">

View File

@@ -259,7 +259,7 @@ export function SystemOverview() {
fetchSystemData().then((data) => {
if (data) setSystemData(data)
})
}, 10000)
}, 9000) // Cambiado de 10000 a 9000ms
return () => {
clearInterval(systemInterval)
@@ -273,7 +273,7 @@ export function SystemOverview() {
}
fetchVMs()
const vmInterval = setInterval(fetchVMs, 60000)
const vmInterval = setInterval(fetchVMs, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(vmInterval)
@@ -290,7 +290,7 @@ export function SystemOverview() {
}
fetchStorage()
const storageInterval = setInterval(fetchStorage, 60000)
const storageInterval = setInterval(fetchStorage, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(storageInterval)
@@ -304,7 +304,7 @@ export function SystemOverview() {
}
fetchNetwork()
const networkInterval = setInterval(fetchNetwork, 60000)
const networkInterval = setInterval(fetchNetwork, 59000) // Cambiado de 60000 a 59000ms
return () => {
clearInterval(networkInterval)

View File

@@ -0,0 +1,261 @@
"use client"
import { useState } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
interface TwoFactorSetupProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
const [step, setStep] = useState(1)
const [qrCode, setQrCode] = useState("")
const [secret, setSecret] = useState("")
const [backupCodes, setBackupCodes] = useState<string[]>([])
const [verificationCode, setVerificationCode] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const [copiedSecret, setCopiedSecret] = useState(false)
const [copiedCodes, setCopiedCodes] = useState(false)
const handleSetupStart = async () => {
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Failed to setup 2FA")
}
setQrCode(data.qr_code)
setSecret(data.secret)
setBackupCodes(data.backup_codes)
setStep(2)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
} finally {
setLoading(false)
}
}
const handleVerify = async () => {
if (!verificationCode || verificationCode.length !== 6) {
setError("Please enter a 6-digit code")
return
}
setError("")
setLoading(true)
try {
const token = localStorage.getItem("proxmenux-auth-token")
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token: verificationCode }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.message || "Invalid verification code")
}
setStep(3)
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed")
} finally {
setLoading(false)
}
}
const copyToClipboard = (text: string, type: "secret" | "codes") => {
navigator.clipboard.writeText(text)
if (type === "secret") {
setCopiedSecret(true)
setTimeout(() => setCopiedSecret(false), 2000)
} else {
setCopiedCodes(true)
setTimeout(() => setCopiedCodes(false), 2000)
}
}
const handleClose = () => {
setStep(1)
setQrCode("")
setSecret("")
setBackupCodes([])
setVerificationCode("")
setError("")
onClose()
}
const handleFinish = () => {
handleClose()
onSuccess()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
Setup Two-Factor Authentication
</DialogTitle>
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
</DialogHeader>
{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>
)}
{step === 1 && (
<div className="space-y-4">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
<p className="text-sm text-blue-500">
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
authentication app in addition to your password.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">You will need:</h4>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
<li>Scan a QR code or enter a key manually</li>
<li>Store backup codes securely</li>
</ul>
</div>
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Starting..." : "Start Setup"}
</Button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">1. Scan the QR code</h4>
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
{qrCode && (
<div className="flex justify-center p-4 bg-white rounded-lg">
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
</div>
)}
</div>
<div className="space-y-2">
<h4 className="font-medium">Or enter the key manually:</h4>
<div className="flex gap-2">
<Input value={secret} readOnly className="font-mono text-sm" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(secret, "secret")}
title="Copy key"
>
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">2. Enter the verification code</h4>
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
<Input
type="text"
placeholder="000000"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className="text-center text-lg tracking-widest font-mono text-base"
maxLength={6}
disabled={loading}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Verifying..." : "Verify and Enable"}
</Button>
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
Cancel
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
<p className="text-sm text-green-500 mt-1">
Your account is now protected with two-factor authentication
</p>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
<p className="text-sm text-muted-foreground">
These codes will allow you to access your account if you lose access to your authentication app. Store
them in a safe place.
</p>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Backup Codes</span>
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
{copiedCodes ? (
<Check className="h-4 w-4 text-green-500 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy All
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
{code}
</div>
))}
</div>
</div>
</div>
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
Finish
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -41,6 +41,7 @@ const DialogContent = React.forwardRef<
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
aria-describedby={props["aria-describedby"] || undefined}
{...props}
>
{children}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import {
Server,
Play,
@@ -264,7 +264,7 @@ export function VirtualMachines() {
isLoading,
mutate,
} = useSWR<VMData[]>("/api/vms", fetcher, {
refreshInterval: 30000,
refreshInterval: 23000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
})
@@ -451,7 +451,7 @@ export function VirtualMachines() {
"/api/system",
fetcher,
{
refreshInterval: 30000,
refreshInterval: 23000,
revalidateOnFocus: false,
},
)
@@ -1042,7 +1042,10 @@ export function VirtualMachines() {
setEditedNotes("")
}}
>
<DialogContent className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogContent
className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden"
key={selectedVM?.vmid || "no-vm"}
>
{currentView === "main" ? (
<>
<DialogHeader className="pb-4 border-b border-border px-6 pt-6">
@@ -1096,13 +1099,16 @@ export function VirtualMachines() {
)}
</div>
</DialogTitle>
<DialogDescription>
View and manage configuration, resources, and status for this virtual machine
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-6">
{selectedVM && (
<>
<div>
<div key={`metrics-${selectedVM.vmid}`}>
<Card
className="cursor-pointer rounded-lg border border-black/10 dark:border-white/10 sm:border-border max-sm:bg-black/5 max-sm:dark:bg-white/5 sm:bg-card sm:hover:bg-black/5 sm:dark:hover:bg-white/5 transition-colors group"
onClick={handleMetricsClick}
@@ -1193,7 +1199,7 @@ export function VirtualMachines() {
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
) : vmDetails?.config ? (
<>
<Card className="border border-border bg-card/50">
<Card className="border border-border bg-card/50" key={`config-${selectedVM.vmid}`}>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
@@ -1259,26 +1265,25 @@ export function VirtualMachines() {
)}
</div>
{/* IP Addresses with proper keys */}
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
IP Addresses
</h4>
<div className="flex flex-wrap gap-2">
{/* Real IPs (green, without "Real" label) */}
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
<Badge
key={`real-${index}`}
key={`real-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20"
>
{ip}
</Badge>
))}
{/* Docker bridge IPs (yellow, with "Bridge" label) */}
{vmDetails.lxc_ip_info.docker_ips.map((ip, index) => (
<Badge
key={`docker-${index}`}
key={`docker-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
variant="outline"
className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
>
@@ -1388,7 +1393,7 @@ export function VirtualMachines() {
</div>
)}
{/* GPU Passthrough */}
{/* GPU Passthrough with proper keys */}
{vmDetails.hardware_info.gpu_passthrough &&
vmDetails.hardware_info.gpu_passthrough.length > 0 && (
<div>
@@ -1396,7 +1401,7 @@ export function VirtualMachines() {
<div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
<Badge
key={index}
key={`gpu-${selectedVM.vmid}-${index}-${gpu.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
variant="outline"
className={
gpu.includes("NVIDIA")
@@ -1411,7 +1416,7 @@ export function VirtualMachines() {
</div>
)}
{/* Other Hardware Devices */}
{/* Hardware Devices with proper keys */}
{vmDetails.hardware_info.devices &&
vmDetails.hardware_info.devices.length > 0 && (
<div>
@@ -1419,7 +1424,7 @@ export function VirtualMachines() {
<div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.devices.map((device, index) => (
<Badge
key={index}
key={`device-${selectedVM.vmid}-${index}-${device.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
variant="outline"
className="bg-blue-500/10 text-blue-500 border-blue-500/20"
>
@@ -1541,7 +1546,7 @@ export function VirtualMachines() {
</h4>
<div className="space-y-3">
{vmDetails.config.rootfs && (
<div>
<div key="rootfs">
<div className="text-xs text-muted-foreground mb-1">Root Filesystem</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.rootfs}
@@ -1549,15 +1554,16 @@ export function VirtualMachines() {
</div>
)}
{vmDetails.config.scsihw && (
<div>
<div key="scsihw">
<div className="text-xs text-muted-foreground mb-1">SCSI Controller</div>
<div className="font-medium text-foreground">{vmDetails.config.scsihw}</div>
</div>
)}
{/* Disk Storage with proper keys */}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
.map((diskKey) => (
<div key={diskKey}>
<div key={`disk-${selectedVM.vmid}-${diskKey}`}>
<div className="text-xs text-muted-foreground mb-1">
{diskKey.toUpperCase().replace(/(\d+)/, " $1")}
</div>
@@ -1567,7 +1573,7 @@ export function VirtualMachines() {
</div>
))}
{vmDetails.config.efidisk0 && (
<div>
<div key="efidisk0">
<div className="text-xs text-muted-foreground mb-1">EFI Disk</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.efidisk0}
@@ -1575,18 +1581,18 @@ export function VirtualMachines() {
</div>
)}
{vmDetails.config.tpmstate0 && (
<div>
<div key="tpmstate0">
<div className="text-xs text-muted-foreground mb-1">TPM State</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.tpmstate0}
</div>
</div>
)}
{/* Mount points for LXC */}
{/* Mount Points with proper keys */}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^mp\d+$/))
.map((mpKey) => (
<div key={mpKey}>
<div key={`mp-${selectedVM.vmid}-${mpKey}`}>
<div className="text-xs text-muted-foreground mb-1">
Mount Point {mpKey.replace("mp", "")}
</div>
@@ -1604,10 +1610,11 @@ export function VirtualMachines() {
Network
</h4>
<div className="space-y-3">
{/* Network Interfaces with proper keys */}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^net\d+$/))
.map((netKey) => (
<div key={netKey}>
<div key={`net-${selectedVM.vmid}-${netKey}`}>
<div className="text-xs text-muted-foreground mb-1">
Network Interface {netKey.replace("net", "")}
</div>
@@ -1645,7 +1652,7 @@ export function VirtualMachines() {
</div>
</div>
{/* PCI Devices Section */}
{/* PCI Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
@@ -1655,7 +1662,7 @@ export function VirtualMachines() {
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^hostpci\d+$/))
.map((pciKey) => (
<div key={pciKey}>
<div key={`pci-${selectedVM.vmid}-${pciKey}`}>
<div className="text-xs text-muted-foreground mb-1">
{pciKey.toUpperCase().replace(/(\d+)/, " $1")}
</div>
@@ -1668,7 +1675,7 @@ export function VirtualMachines() {
</div>
)}
{/* USB Devices Section */}
{/* USB Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
@@ -1678,7 +1685,7 @@ export function VirtualMachines() {
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^usb\d+$/))
.map((usbKey) => (
<div key={usbKey}>
<div key={`usb-${selectedVM.vmid}-${usbKey}`}>
<div className="text-xs text-muted-foreground mb-1">
{usbKey.toUpperCase().replace(/(\d+)/, " $1")}
</div>
@@ -1691,7 +1698,7 @@ export function VirtualMachines() {
</div>
)}
{/* Serial Devices Section */}
{/* Serial Ports with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
@@ -1701,7 +1708,7 @@ export function VirtualMachines() {
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^serial\d+$/))
.map((serialKey) => (
<div key={serialKey}>
<div key={`serial-${selectedVM.vmid}-${serialKey}`}>
<div className="text-xs text-muted-foreground mb-1">
{serialKey.toUpperCase().replace(/(\d+)/, " $1")}
</div>
@@ -1713,91 +1720,6 @@ export function VirtualMachines() {
</div>
</div>
)}
{/* Options Section */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
Options
</h4>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{vmDetails.config.onboot !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Start on Boot</div>
<Badge
variant="outline"
className={
vmDetails.config.onboot
? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{vmDetails.config.onboot ? "Yes" : "No"}
</Badge>
</div>
)}
{vmDetails.config.ostype && (
<div>
<div className="text-xs text-muted-foreground mb-1">OS Type</div>
<div className="font-medium text-foreground">{vmDetails.config.ostype}</div>
</div>
)}
{vmDetails.config.arch && (
<div>
<div className="text-xs text-muted-foreground mb-1">Architecture</div>
<div className="font-medium text-foreground">{vmDetails.config.arch}</div>
</div>
)}
{vmDetails.config.boot && (
<div>
<div className="text-xs text-muted-foreground mb-1">Boot Order</div>
<div className="font-medium text-foreground">{vmDetails.config.boot}</div>
</div>
)}
{vmDetails.config.features && (
<div className="col-span-2 lg:grid-cols-3">
<div className="text-xs text-muted-foreground mb-1">Features</div>
<div className="font-medium text-foreground text-sm">
{vmDetails.config.features}
</div>
</div>
)}
</div>
</div>
{/* Advanced Section */}
{(vmDetails.config.vmgenid || vmDetails.config.smbios1 || vmDetails.config.meta) && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
Advanced
</h4>
<div className="space-y-3">
{vmDetails.config.vmgenid && (
<div>
<div className="text-xs text-muted-foreground mb-1">VM Generation ID</div>
<div className="font-medium text-muted-foreground text-sm font-mono">
{vmDetails.config.vmgenid}
</div>
</div>
)}
{vmDetails.config.smbios1 && (
<div>
<div className="text-xs text-muted-foreground mb-1">SMBIOS</div>
<div className="font-medium text-muted-foreground text-sm font-mono break-all">
{vmDetails.config.smbios1}
</div>
</div>
)}
{vmDetails.config.meta && (
<div>
<div className="text-xs text-muted-foreground mb-1">Metadata</div>
<div className="font-medium text-muted-foreground text-sm font-mono">
{vmDetails.config.meta}
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</CardContent>

View File

@@ -0,0 +1,85 @@
"use client"
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
export interface PollingIntervals {
storage: number
network: number
vms: number
hardware: number
}
// Default intervals in milliseconds
const DEFAULT_INTERVALS: PollingIntervals = {
storage: 60000, // 60 seconds
network: 60000, // 60 seconds
vms: 30000, // 30 seconds
hardware: 60000, // 60 seconds
}
const STORAGE_KEY = "proxmenux_polling_intervals"
interface PollingConfigContextType {
intervals: PollingIntervals
updateInterval: (key: keyof PollingIntervals, value: number) => void
}
const PollingConfigContext = createContext<PollingConfigContextType | undefined>(undefined)
export function PollingConfigProvider({ children }: { children: ReactNode }) {
const [intervals, setIntervals] = useState<PollingIntervals>(DEFAULT_INTERVALS)
// Load from localStorage on mount
useEffect(() => {
if (typeof window === "undefined") return
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
setIntervals({ ...DEFAULT_INTERVALS, ...parsed })
} catch (e) {
console.error("[v0] Failed to parse stored polling intervals:", e)
}
}
}, [])
const updateInterval = (key: keyof PollingIntervals, value: number) => {
setIntervals((prev) => {
const newIntervals = { ...prev, [key]: value }
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newIntervals))
}
return newIntervals
})
}
return <PollingConfigContext.Provider value={{ intervals, updateInterval }}>{children}</PollingConfigContext.Provider>
}
export function usePollingConfig() {
const context = useContext(PollingConfigContext)
if (!context) {
// During SSR or when provider is not available, return defaults
if (typeof window === "undefined") {
return {
intervals: DEFAULT_INTERVALS,
updateInterval: () => {},
}
}
throw new Error("usePollingConfig must be used within PollingConfigProvider")
}
return context
}
// Interval options for the UI (in milliseconds)
export const INTERVAL_OPTIONS = [
{ label: "10 seconds", value: 10000 },
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000 },
{ label: "10 minutes", value: 600000 },
{ label: "30 minutes", value: 1800000 },
{ label: "1 hour", value: 3600000 },
]

View File

@@ -5,11 +5,13 @@ Handles all authentication-related operations including:
- Password hashing and verification
- JWT token generation and validation
- Auth status checking
- Two-Factor Authentication (2FA/TOTP)
"""
import os
import json
import hashlib
import secrets
from datetime import datetime, timedelta
from pathlib import Path
@@ -20,6 +22,16 @@ except ImportError:
JWT_AVAILABLE = False
print("Warning: PyJWT not available. Authentication features will be limited.")
try:
import pyotp
import segno
import io
import base64
TOTP_AVAILABLE = True
except ImportError:
TOTP_AVAILABLE = False
print("Warning: pyotp/segno not available. 2FA features will be disabled.")
# Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
@@ -41,8 +53,11 @@ def load_auth_config():
"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)
"declined": bool,
"configured": bool,
"totp_enabled": bool, # 2FA enabled flag
"totp_secret": str, # TOTP secret key
"backup_codes": list # List of backup codes
}
"""
if not AUTH_CONFIG_FILE.exists():
@@ -51,7 +66,10 @@ def load_auth_config():
"username": None,
"password_hash": None,
"declined": False,
"configured": False
"configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
}
try:
@@ -60,6 +78,9 @@ def load_auth_config():
# Ensure all required fields exist
config.setdefault("declined", False)
config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
config.setdefault("totp_enabled", False)
config.setdefault("totp_secret", None)
config.setdefault("backup_codes", [])
return config
except Exception as e:
print(f"Error loading auth config: {e}")
@@ -68,7 +89,10 @@ def load_auth_config():
"username": None,
"password_hash": None,
"declined": False,
"configured": False
"configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
}
@@ -141,16 +165,18 @@ def get_auth_status():
"auth_configured": bool,
"declined": bool,
"username": str or None,
"authenticated": bool
"authenticated": bool,
"totp_enabled": bool # 2FA status
}
"""
config = load_auth_config()
return {
"auth_enabled": config.get("enabled", False),
"auth_configured": config.get("configured", False), # Frontend expects this field name
"auth_configured": config.get("configured", False),
"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
"authenticated": False,
"totp_enabled": config.get("totp_enabled", False) # Include 2FA status
}
@@ -170,7 +196,10 @@ def setup_auth(username, password):
"username": username,
"password_hash": hash_password(password),
"declined": False,
"configured": True
"configured": True,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
}
if save_auth_config(config):
@@ -190,6 +219,9 @@ def decline_auth():
config["configured"] = True
config["username"] = None
config["password_hash"] = None
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config):
return True, "Authentication declined"
@@ -204,8 +236,13 @@ def disable_auth():
"""
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
config["username"] = None
config["password_hash"] = None
config["declined"] = False
config["configured"] = False
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config):
return True, "Authentication disabled"
@@ -256,24 +293,212 @@ def change_password(old_password, new_password):
return False, "Failed to save new password"
def authenticate(username, password):
def generate_totp_secret():
"""Generate a new TOTP secret key"""
if not TOTP_AVAILABLE:
return None
return pyotp.random_base32()
def generate_totp_qr(username, secret):
"""
Authenticate a user with username and password
Returns (success: bool, token: str or None, message: str)
Generate a QR code for TOTP setup
Returns base64 encoded SVG image
"""
if not TOTP_AVAILABLE:
return None
try:
# Create TOTP URI
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=username,
issuer_name="ProxMenux Monitor"
)
qr = segno.make(uri)
# Convert to SVG string
buffer = io.BytesIO()
qr.save(buffer, kind='svg', scale=4, border=2)
svg_bytes = buffer.getvalue()
svg_content = svg_bytes.decode('utf-8')
# Return as data URL
svg_base64 = base64.b64encode(svg_content.encode()).decode('utf-8')
return f"data:image/svg+xml;base64,{svg_base64}"
except Exception as e:
print(f"Error generating QR code: {e}")
return None
def generate_backup_codes(count=8):
"""Generate backup codes for 2FA recovery"""
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
# Format as XXXX-XXXX for readability
formatted = f"{code[:4]}-{code[4:]}"
codes.append({
"code": hashlib.sha256(formatted.encode()).hexdigest(),
"used": False
})
return codes
def setup_totp(username):
"""
Set up TOTP for a user
Returns (success: bool, secret: str, qr_code: str, backup_codes: list, message: str)
"""
if not TOTP_AVAILABLE:
return False, None, None, None, "2FA is not available (pyotp/segno not installed)"
config = load_auth_config()
if not config.get("enabled"):
return False, None, None, None, "Authentication must be enabled first"
if config.get("username") != username:
return False, None, None, None, "Invalid username"
# Generate new secret and backup codes
secret = generate_totp_secret()
qr_code = generate_totp_qr(username, secret)
backup_codes_plain = []
backup_codes_hashed = generate_backup_codes()
# Generate plain text backup codes for display (only returned once)
for i in range(8):
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
formatted = f"{code[:4]}-{code[4:]}"
backup_codes_plain.append(formatted)
backup_codes_hashed[i]["code"] = hashlib.sha256(formatted.encode()).hexdigest()
# Store secret and hashed backup codes (not enabled yet until verified)
config["totp_secret"] = secret
config["backup_codes"] = backup_codes_hashed
if save_auth_config(config):
return True, secret, qr_code, backup_codes_plain, "2FA setup initiated"
else:
return False, None, None, None, "Failed to save 2FA configuration"
def verify_totp(username, token, use_backup=False):
"""
Verify a TOTP token or backup code
Returns (success: bool, message: str)
"""
if not TOTP_AVAILABLE and not use_backup:
return False, "2FA is not available"
config = load_auth_config()
if not config.get("totp_enabled"):
return False, "2FA is not enabled"
if config.get("username") != username:
return False, "Invalid username"
# Check backup code
if use_backup:
token_hash = hashlib.sha256(token.encode()).hexdigest()
for backup_code in config.get("backup_codes", []):
if backup_code["code"] == token_hash and not backup_code["used"]:
backup_code["used"] = True
save_auth_config(config)
return True, "Backup code accepted"
return False, "Invalid or already used backup code"
# Check TOTP token
totp = pyotp.TOTP(config.get("totp_secret"))
if totp.verify(token, valid_window=1): # Allow 1 time step tolerance
return True, "2FA verification successful"
else:
return False, "Invalid 2FA code"
def enable_totp(username, verification_token):
"""
Enable TOTP after successful verification
Returns (success: bool, message: str)
"""
if not TOTP_AVAILABLE:
return False, "2FA is not available"
config = load_auth_config()
if not config.get("totp_secret"):
return False, "2FA has not been set up. Please set up 2FA first."
if config.get("username") != username:
return False, "Invalid username"
# Verify the token before enabling
totp = pyotp.TOTP(config.get("totp_secret"))
if not totp.verify(verification_token, valid_window=1):
return False, "Invalid verification code. Please try again."
config["totp_enabled"] = True
if save_auth_config(config):
return True, "2FA enabled successfully"
else:
return False, "Failed to enable 2FA"
def disable_totp(username, password):
"""
Disable TOTP (requires password confirmation)
Returns (success: bool, message: str)
"""
config = load_auth_config()
if config.get("username") != username:
return False, "Invalid username"
if not verify_password(password, config.get("password_hash", "")):
return False, "Invalid password"
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config):
return True, "2FA disabled successfully"
else:
return False, "Failed to disable 2FA"
def authenticate(username, password, totp_token=None):
"""
Authenticate a user with username, password, and optional TOTP
Returns (success: bool, token: str or None, requires_totp: bool, message: str)
"""
config = load_auth_config()
if not config.get("enabled"):
return False, None, "Authentication is not enabled"
return False, None, False, "Authentication is not enabled"
if username != config.get("username"):
return False, None, "Invalid username or password"
return False, None, False, "Invalid username or password"
if not verify_password(password, config.get("password_hash", "")):
return False, None, "Invalid username or password"
return False, None, False, "Invalid username or password"
if config.get("totp_enabled"):
if not totp_token:
return False, None, True, "2FA code required"
# Verify TOTP token or backup code
success, message = verify_totp(username, totp_token, use_backup=len(totp_token) == 9) # Backup codes are formatted XXXX-XXXX
if not success:
return False, None, True, message
token = generate_token(username)
if token:
return True, token, "Authentication successful"
return True, token, False, "Authentication successful"
else:
return False, None, "Failed to generate authentication token"
return False, None, False, "Failed to generate authentication token"

View File

@@ -284,6 +284,8 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
psutil \
requests \
PyJWT \
pyotp \
segno \
googletrans==4.0.0-rc1 \
httpx==0.13.3 \
httpcore==0.9.1 \

View File

@@ -64,11 +64,14 @@ def auth_login():
data = request.json
username = data.get('username')
password = data.get('password')
totp_token = data.get('totp_token') # Optional 2FA token
success, token, message = auth_manager.authenticate(username, password)
success, token, requires_totp, message = auth_manager.authenticate(username, password, totp_token)
if success:
return jsonify({"success": True, "token": token, "message": message})
elif requires_totp:
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else:
return jsonify({"success": False, "message": message}), 401
except Exception as e:
@@ -93,6 +96,10 @@ def auth_enable():
def auth_disable():
"""Disable authentication"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, message = auth_manager.disable_auth()
if success:
@@ -119,3 +126,95 @@ def auth_change_password():
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@auth_bp.route('/api/auth/skip', methods=['POST'])
def auth_skip():
"""Skip authentication setup (same as decline)"""
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/totp/setup', methods=['POST'])
def totp_setup():
"""Initialize TOTP setup for a user"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
if success:
return jsonify({
"success": True,
"secret": secret,
"qr_code": qr_code,
"backup_codes": backup_codes,
"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/totp/enable', methods=['POST'])
def totp_enable():
"""Enable TOTP after verification"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
data = request.json
verification_token = data.get('token')
if not verification_token:
return jsonify({"success": False, "message": "Verification token required"}), 400
success, message = auth_manager.enable_totp(username, verification_token)
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/totp/disable', methods=['POST'])
def totp_disable():
"""Disable TOTP (requires password confirmation)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
data = request.json
password = data.get('password')
if not password:
return jsonify({"success": False, "message": "Password required"}), 400
success, message = auth_manager.disable_totp(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

View File

@@ -24,3 +24,16 @@ def get_health_details():
return jsonify(details)
except Exception as e:
return jsonify({'error': str(e)}), 500
@health_bp.route('/api/system-info', methods=['GET'])
def get_system_info():
"""
Get lightweight system info for header display.
Returns: hostname, uptime, and cached health status.
This is optimized for minimal server impact.
"""
try:
info = health_monitor.get_system_info()
return jsonify(info)
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -950,31 +950,37 @@ def get_pcie_link_speed(disk_name):
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}")
# print(f"[v0] Could not extract controller from {disk_name}")
pass
return pcie_info
controller = match.group(1) # nvme0n1 -> nvme0
print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
# print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
pass
# 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)}")
# print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
pass
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}")
# print(f"[v0] PCI address for {disk_name}: {pci_address}")
pass
# 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}:")
# print(f"[v0] lspci output for {pci_address}:")
pass
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}")
# print(f"[v0] Found LnkSta: {line}")
pass
# Example: "LnkSta: Speed 8GT/s, Width x4"
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
@@ -990,17 +996,20 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
# print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
pass
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']}")
# print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
pass
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
# print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1015,39 +1024,48 @@ def get_pcie_link_speed(disk_name):
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']}")
# print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
pass
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']}")
# print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
pass
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
# print(f"[v0] lspci failed with return code: {result.returncode}")
pass
except Exception as e:
print(f"[v0] Error getting PCIe info via lspci: {e}")
# print(f"[v0] Error getting PCIe info via lspci: {e}")
pass
import traceback
traceback.print_exc()
else:
print(f"[v0] sys_path does not exist: {sys_path}")
# print(f"[v0] sys_path does not exist: {sys_path}")
pass
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)}")
# print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
pass
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}")
# print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
pass
# 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):")
# print(f"[v0] lspci output for {pci_address} (from alt path):")
pass
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}")
# print(f"[v0] Found LnkSta: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1062,17 +1080,20 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
# print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
pass
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']}")
# print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
pass
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
# print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1087,26 +1108,32 @@ def get_pcie_link_speed(disk_name):
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']}")
# print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
pass
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']}")
# print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
pass
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
# print(f"[v0] lspci failed with return code: {result.returncode}")
pass
except Exception as e:
print(f"[v0] Error getting PCIe info from alt path: {e}")
# print(f"[v0] Error getting PCIe info from alt path: {e}")
pass
import traceback
traceback.print_exc()
except Exception as e:
print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
# print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
pass
import traceback
traceback.print_exc()
print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
# print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
pass
return pcie_info
# get_pcie_link_speed function definition ends here
@@ -5397,7 +5424,7 @@ def api_health():
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'version': '1.0.0'
'version': '1.0.1'
})
@app.route('/api/prometheus', methods=['GET'])
@@ -5655,57 +5682,6 @@ def api_prometheus():
traceback.print_exc()
return f'# Error generating metrics: {str(e)}\n', 500, {'Content-Type': 'text/plain; charset=utf-8'}
@app.route('/api/system-info', methods=['GET'])
def api_system_info():
"""Get system and node information for dashboard header"""
try:
hostname = socket.gethostname()
node_id = f"pve-{hostname}"
pve_version = None
# Try to get Proxmox version
try:
result = subprocess.run(['pveversion'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
pve_version = result.stdout.strip().split('\n')[0]
except:
pass
# 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)
if result.returncode == 0:
nodes = json.loads(result.stdout)
if nodes and len(nodes) > 0:
node_info = nodes[0]
node_id = node_info.get('node', node_id)
hostname = node_info.get('node', hostname)
except:
pass
response = {
'hostname': hostname,
'node_id': node_id,
'status': 'online',
'timestamp': datetime.now().isoformat()
}
if pve_version:
response['pve_version'] = pve_version
else:
response['error'] = 'Proxmox version not available - pveversion command not found'
return jsonify(response)
except Exception as e:
# print(f"Error getting system info: {e}")
pass
return jsonify({
'error': f'Unable to access system information: {str(e)}',
'hostname': socket.gethostname(),
'status': 'error',
'timestamp': datetime.now().isoformat()
})
@app.route('/api/info', methods=['GET'])
def api_info():

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -89,9 +89,9 @@ function select_nas_iso() {
HN="OpenMediaVault"
;;
5)
ISO_NAME="XigmaNAS-13.3.0.5"
ISO_URL="https://sourceforge.net/projects/xigmanas/files/XigmaNAS-13.3.0.5/13.3.0.5.10153/XigmaNAS-x64-LiveCD-13.3.0.5.10153.iso/download"
ISO_FILE="XigmaNAS-x64-LiveCD-13.3.0.5.10153.iso"
ISO_NAME="XigmaNAS-14.3.0.5"
ISO_URL="https://sourceforge.net/projects/xigmanas/files/XigmaNAS-14.3.0.5/14.3.0.5.10566/XigmaNAS-x64-LiveCD-14.3.0.5.10566.iso/download"
ISO_FILE="XigmaNAS-x64-LiveCD-14.3.0.5.10566.iso"
ISO_PATH="$ISO_DIR/$ISO_FILE"
HN="XigmaNAS"
;;