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

View File

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

View File

@@ -1,7 +1,85 @@
"use client" "use client"
import { useState, useEffect } from "react"
import { ProxmoxDashboard } from "../components/proxmox-dashboard" 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() { 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
{step === "choice" ? ( {step === "choice" ? (
<div className="space-y-6"> <div className="space-y-6 py-2">
<div className="text-center space-y-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"> <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" /> <Shield className="h-8 w-8 text-blue-500" />
</div> </div>
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2> <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. Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
</p> </p>
</div> </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> <p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6 py-2">
<div className="text-center space-y-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"> <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" /> <Lock className="h-8 w-8 text-blue-500" />
</div> </div>
<h2 className="text-2xl font-bold">Setup Authentication</h2> <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> </div>
{error && ( {error && (
@@ -179,7 +179,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username" className="text-sm">
Username
</Label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -188,14 +190,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Enter username" placeholder="Enter username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="pl-10" className="pl-10 text-base"
disabled={loading} disabled={loading}
autoComplete="username"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password" className="text-sm">
Password
</Label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -204,14 +209,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Enter password" placeholder="Enter password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="pl-10" className="pl-10 text-base"
disabled={loading} disabled={loading}
autoComplete="new-password"
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <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"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -220,8 +228,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
placeholder="Confirm password" placeholder="Confirm password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10" className="pl-10 text-base"
disabled={loading} disabled={loading}
autoComplete="new-password"
/> />
</div> </div>
</div> </div>

View File

@@ -163,14 +163,49 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
} }
export default function Hardware() { export default function Hardware() {
// Static data - load once without refresh
const { const {
data: hardwareData, data: staticHardwareData,
error, error: staticError,
isLoading, isLoading: staticLoading,
} = useSWR<HardwareData>("/api/hardware", fetcher, { } = 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(() => { useEffect(() => {
if (hardwareData?.storage_devices) { if (hardwareData?.storage_devices) {
console.log("[v0] Storage devices data from backend:", 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 type React from "react"
import { useState } from "react" import { useState, useEffect } from "react"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" 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 { getApiUrl } from "../lib/api-config"
import Image from "next/image" import Image from "next/image"
@@ -17,9 +18,23 @@ interface LoginProps {
export function Login({ onLogin }: LoginProps) { export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("") const [username, setUsername] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [totpCode, setTotpCode] = useState("")
const [requiresTotp, setRequiresTotp] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [loading, setLoading] = useState(false) 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("") setError("")
@@ -29,23 +44,46 @@ export function Login({ onLogin }: LoginProps) {
return return
} }
if (requiresTotp && !totpCode) {
setError("Please enter your 2FA code")
return
}
setLoading(true) setLoading(true)
try { try {
const response = await fetch(getApiUrl("/api/auth/login"), { const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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() const data = await response.json()
if (!response.ok) { if (data.requires_totp) {
throw new Error(data.error || "Login failed") setRequiresTotp(true)
setLoading(false)
return
}
if (!response.ok) {
throw new Error(data.message || "Login failed")
} }
// Save token
localStorage.setItem("proxmenux-auth-token", data.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() onLogin()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Login failed") setError(err instanceof Error ? err.message : "Login failed")
@@ -94,42 +132,107 @@ export function Login({ onLogin }: LoginProps) {
</div> </div>
)} )}
<div className="space-y-2"> {!requiresTotp ? (
<Label htmlFor="login-username">Username</Label> <>
<div className="relative"> <div className="space-y-2">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Label htmlFor="login-username" className="text-sm">
<Input Username
id="login-username" </Label>
type="text" <div className="relative">
placeholder="Enter your username" <User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
value={username} <Input
onChange={(e) => setUsername(e.target.value)} id="login-username"
className="pl-10" type="text"
disabled={loading} placeholder="Enter your username"
autoComplete="username" value={username}
/> onChange={(e) => setUsername(e.target.value)}
</div> className="pl-10 text-base"
</div> disabled={loading}
autoComplete="username"
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="login-password">Password</Label> <Label htmlFor="login-password" className="text-sm">
<div className="relative"> Password
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> </Label>
<Input <div className="relative">
id="login-password" <Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
type="password" <Input
placeholder="Enter your password" id="login-password"
value={password} type="password"
onChange={(e) => setPassword(e.target.value)} placeholder="Enter your password"
className="pl-10" value={password}
disabled={loading} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" 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>
</div> )}
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}> <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> </Button>
</form> </form>
</div> </div>

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge" 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 { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react"
import useSWR from "swr" import useSWR from "swr"
import { NetworkTrafficChart } from "./network-traffic-chart" import { NetworkTrafficChart } from "./network-traffic-chart"
@@ -149,7 +149,7 @@ export function NetworkMetrics() {
error, error,
isLoading, isLoading,
} = useSWR<NetworkData>("/api/network", fetcher, { } = useSWR<NetworkData>("/api/network", fetcher, {
refreshInterval: 60000, // Refresh every 60 seconds refreshInterval: 53000,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}) })
@@ -161,13 +161,13 @@ export function NetworkMetrics() {
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const { data: modalNetworkData } = useSWR<NetworkData>(selectedInterface ? "/api/network" : null, fetcher, { const { data: modalNetworkData } = useSWR<NetworkData>(selectedInterface ? "/api/network" : null, fetcher, {
refreshInterval: 15000, // Refresh every 15 seconds when modal is open refreshInterval: 17000,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}) })
const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, { const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, {
refreshInterval: 30000, refreshInterval: 29000,
revalidateOnFocus: false, revalidateOnFocus: false,
}) })
@@ -688,6 +688,9 @@ export function NetworkMetrics() {
<Router className="h-5 w-5" /> <Router className="h-5 w-5" />
{selectedInterface?.name} - Interface Details {selectedInterface?.name} - Interface Details
</DialogTitle> </DialogTitle>
<DialogDescription>
View detailed information and network traffic statistics for this interface
</DialogDescription>
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && ( {selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Select value={modalTimeframe} onValueChange={(value: any) => setModalTimeframe(value)}> <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 { VirtualMachines } from "./virtual-machines"
import Hardware from "./hardware" import Hardware from "./hardware"
import { SystemLogs } from "./system-logs" import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
import { OnboardingCarousel } from "./onboarding-carousel" import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal" import { HealthStatusModal } from "./health-status-modal"
import { getApiUrl } from "../lib/api-config" import { getApiUrl } from "../lib/api-config"
@@ -26,6 +27,7 @@ import {
Box, Box,
Cpu, Cpu,
FileText, FileText,
SettingsIcon,
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import { ThemeToggle } from "./theme-toggle" import { ThemeToggle } from "./theme-toggle"
@@ -49,11 +51,18 @@ interface FlaskSystemData {
load_average: number[] load_average: number[]
} }
interface FlaskSystemInfo {
hostname: string
node_id: string
uptime: string
health_status: "healthy" | "warning" | "critical"
}
export function ProxmoxDashboard() { export function ProxmoxDashboard() {
const [systemStatus, setSystemStatus] = useState<SystemStatus>({ const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy", status: "healthy",
uptime: "Loading...", uptime: "Loading...",
lastUpdate: new Date().toLocaleTimeString(), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: "Loading...", serverName: "Loading...",
nodeId: "Loading...", nodeId: "Loading...",
}) })
@@ -67,12 +76,7 @@ export function ProxmoxDashboard() {
const [showHealthModal, setShowHealthModal] = useState(false) const [showHealthModal, setShowHealthModal] = useState(false)
const fetchSystemData = useCallback(async () => { const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...") const apiUrl = getApiUrl("/api/system-info")
console.log("[v0] Current window location:", window.location.href)
const apiUrl = getApiUrl("/api/system")
console.log("[v0] API URL:", apiUrl)
try { try {
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
@@ -82,37 +86,26 @@ export function ProxmoxDashboard() {
}, },
cache: "no-store", cache: "no-store",
}) })
console.log("[v0] Response status:", response.status)
if (!response.ok) { if (!response.ok) {
throw new Error(`Server responded with status: ${response.status}`) throw new Error(`Server responded with status: ${response.status}`)
} }
const data: FlaskSystemData = await response.json() const data: FlaskSystemInfo = await response.json()
console.log("[v0] System data received:", data)
let status: "healthy" | "warning" | "critical" = "healthy" const uptimeValue =
if (data.cpu_usage > 90 || data.memory_usage > 90) { data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
status = "critical"
} else if (data.cpu_usage > 75 || data.memory_usage > 75) {
status = "warning"
}
setSystemStatus({ setSystemStatus({
status, status: data.health_status || "healthy",
uptime: data.uptime, uptime: uptimeValue,
lastUpdate: new Date().toLocaleTimeString(), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
serverName: data.hostname, serverName: data.hostname || "Unknown",
nodeId: data.node_id, nodeId: data.node_id || "Unknown",
}) })
setIsServerConnected(true) setIsServerConnected(true)
} catch (error) { } catch (error) {
console.error("[v0] Failed to fetch system data from Flask server:", 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) setIsServerConnected(false)
setSystemStatus((prev) => ({ setSystemStatus((prev) => ({
@@ -121,16 +114,26 @@ export function ProxmoxDashboard() {
serverName: "Server Offline", serverName: "Server Offline",
nodeId: "Server Offline", nodeId: "Server Offline",
uptime: "N/A", uptime: "N/A",
lastUpdate: new Date().toLocaleTimeString(), lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
})) }))
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
// Siempre fetch inicial
fetchSystemData() fetchSystemData()
const interval = setInterval(fetchSystemData, 10000)
return () => clearInterval(interval) let interval: ReturnType<typeof setInterval> | null = null
}, [fetchSystemData]) 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(() => { useEffect(() => {
if ( if (
@@ -216,6 +219,8 @@ export function ProxmoxDashboard() {
return "Hardware" return "Hardware"
case "logs": case "logs":
return "System Logs" return "System Logs"
case "settings":
return "Settings"
default: default:
return "Navigation Menu" return "Navigation Menu"
} }
@@ -299,7 +304,9 @@ export function ProxmoxDashboard() {
<span className="ml-1 capitalize">{systemStatus.status}</span> <span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge> </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 <Button
variant="outline" variant="outline"
@@ -348,7 +355,7 @@ export function ProxmoxDashboard() {
{/* Mobile Server Info */} {/* Mobile Server Info */}
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground"> <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>
</div> </div>
</header> </header>
@@ -362,7 +369,7 @@ export function ProxmoxDashboard() {
> >
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6"> <div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0"> <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 <TabsTrigger
value="overview" value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md" 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 System Logs
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="settings"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Settings
</TabsTrigger>
</TabsList> </TabsList>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}> <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
@@ -507,6 +520,21 @@ export function ProxmoxDashboard() {
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
<span>System Logs</span> <span>System Logs</span>
</Button> </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> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -539,10 +567,14 @@ export function ProxmoxDashboard() {
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
<SystemLogs key={`logs-${componentKey}`} /> <SystemLogs key={`logs-${componentKey}`} />
</TabsContent> </TabsContent>
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
<Settings />
</TabsContent>
</Tabs> </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"> <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> <p>
<a <a
href="https://ko-fi.com/macrimi" href="https://ko-fi.com/macrimi"

View File

@@ -5,11 +5,13 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" 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 { getApiUrl } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
export function Settings() { export function Settings() {
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [success, setSuccess] = useState("") const [success, setSuccess] = useState("")
@@ -26,6 +28,10 @@ export function Settings() {
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [confirmNewPassword, setConfirmNewPassword] = useState("") const [confirmNewPassword, setConfirmNewPassword] = useState("")
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("")
useEffect(() => { useEffect(() => {
checkAuthStatus() checkAuthStatus()
}, []) }, [])
@@ -35,6 +41,7 @@ export function Settings() {
const response = await fetch(getApiUrl("/api/auth/status")) const response = await fetch(getApiUrl("/api/auth/status"))
const data = await response.json() const data = await response.json()
setAuthEnabled(data.auth_enabled || false) setAuthEnabled(data.auth_enabled || false)
setTotpEnabled(data.totp_enabled || false) // Get 2FA status
} catch (err) { } catch (err) {
console.error("Failed to check auth status:", err) console.error("Failed to check auth status:", err)
} }
@@ -109,19 +116,31 @@ export function Settings() {
setSuccess("") setSuccess("")
try { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
body: JSON.stringify({ enable_auth: false }), "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") localStorage.removeItem("proxmenux-auth-token")
setSuccess("Authentication disabled successfully!") localStorage.removeItem("proxmenux-auth-setup-complete")
setAuthEnabled(false)
setSuccess("Authentication disabled successfully! Reloading...")
setTimeout(() => {
window.location.reload()
}, 1000)
} catch (err) { } catch (err) {
setError("Failed to disable authentication. Please try again.") setError(err instanceof Error ? err.message : "Failed to disable authentication. Please try again.")
} finally { } finally {
setLoading(false) 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 = () => { const handleLogout = () => {
localStorage.removeItem("proxmenux-auth-token") localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-auth-setup-complete")
window.location.reload() window.location.reload()
} }
@@ -322,6 +382,7 @@ export function Settings() {
{authEnabled && ( {authEnabled && (
<div className="space-y-3"> <div className="space-y-3">
<Button onClick={handleLogout} variant="outline" className="w-full bg-transparent"> <Button onClick={handleLogout} variant="outline" className="w-full bg-transparent">
<LogOut className="h-4 w-4 mr-2" />
Logout Logout
</Button> </Button>
@@ -404,6 +465,74 @@ export function Settings() {
</div> </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}> <Button onClick={handleDisableAuth} variant="destructive" className="w-full" disabled={loading}>
Disable Authentication Disable Authentication
</Button> </Button>
@@ -429,6 +558,15 @@ export function Settings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
onSuccess={() => {
setSuccess("2FA enabled successfully!")
checkAuthStatus()
}}
/>
</div> </div>
) )
} }

View File

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

View File

@@ -259,7 +259,7 @@ export function SystemOverview() {
fetchSystemData().then((data) => { fetchSystemData().then((data) => {
if (data) setSystemData(data) if (data) setSystemData(data)
}) })
}, 10000) }, 9000) // Cambiado de 10000 a 9000ms
return () => { return () => {
clearInterval(systemInterval) clearInterval(systemInterval)
@@ -273,7 +273,7 @@ export function SystemOverview() {
} }
fetchVMs() fetchVMs()
const vmInterval = setInterval(fetchVMs, 60000) const vmInterval = setInterval(fetchVMs, 59000) // Cambiado de 60000 a 59000ms
return () => { return () => {
clearInterval(vmInterval) clearInterval(vmInterval)
@@ -290,7 +290,7 @@ export function SystemOverview() {
} }
fetchStorage() fetchStorage()
const storageInterval = setInterval(fetchStorage, 60000) const storageInterval = setInterval(fetchStorage, 59000) // Cambiado de 60000 a 59000ms
return () => { return () => {
clearInterval(storageInterval) clearInterval(storageInterval)
@@ -304,7 +304,7 @@ export function SystemOverview() {
} }
fetchNetwork() fetchNetwork()
const networkInterval = setInterval(fetchNetwork, 60000) const networkInterval = setInterval(fetchNetwork, 59000) // Cambiado de 60000 a 59000ms
return () => { return () => {
clearInterval(networkInterval) 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", "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, className,
)} )}
aria-describedby={props["aria-describedby"] || undefined}
{...props} {...props}
> >
{children} {children}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress" import { Progress } from "./ui/progress"
import { Button } from "./ui/button" import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { import {
Server, Server,
Play, Play,
@@ -264,7 +264,7 @@ export function VirtualMachines() {
isLoading, isLoading,
mutate, mutate,
} = useSWR<VMData[]>("/api/vms", fetcher, { } = useSWR<VMData[]>("/api/vms", fetcher, {
refreshInterval: 30000, refreshInterval: 23000,
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
}) })
@@ -451,7 +451,7 @@ export function VirtualMachines() {
"/api/system", "/api/system",
fetcher, fetcher,
{ {
refreshInterval: 30000, refreshInterval: 23000,
revalidateOnFocus: false, revalidateOnFocus: false,
}, },
) )
@@ -1042,7 +1042,10 @@ export function VirtualMachines() {
setEditedNotes("") 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" ? ( {currentView === "main" ? (
<> <>
<DialogHeader className="pb-4 border-b border-border px-6 pt-6"> <DialogHeader className="pb-4 border-b border-border px-6 pt-6">
@@ -1096,13 +1099,16 @@ export function VirtualMachines() {
)} )}
</div> </div>
</DialogTitle> </DialogTitle>
<DialogDescription>
View and manage configuration, resources, and status for this virtual machine
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-6"> <div className="space-y-6">
{selectedVM && ( {selectedVM && (
<> <>
<div> <div key={`metrics-${selectedVM.vmid}`}>
<Card <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" 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} onClick={handleMetricsClick}
@@ -1193,7 +1199,7 @@ export function VirtualMachines() {
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div> <div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
) : vmDetails?.config ? ( ) : 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"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> <h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
@@ -1259,26 +1265,25 @@ export function VirtualMachines() {
)} )}
</div> </div>
{/* IP Addresses with proper keys */}
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && ( {selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border"> <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"> <h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
IP Addresses IP Addresses
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* Real IPs (green, without "Real" label) */}
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => ( {vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
<Badge <Badge
key={`real-${index}`} key={`real-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
variant="outline" variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20" className="bg-green-500/10 text-green-500 border-green-500/20"
> >
{ip} {ip}
</Badge> </Badge>
))} ))}
{/* Docker bridge IPs (yellow, with "Bridge" label) */}
{vmDetails.lxc_ip_info.docker_ips.map((ip, index) => ( {vmDetails.lxc_ip_info.docker_ips.map((ip, index) => (
<Badge <Badge
key={`docker-${index}`} key={`docker-ip-${selectedVM.vmid}-${ip.replace(/[.:/]/g, "-")}-${index}`}
variant="outline" variant="outline"
className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
> >
@@ -1388,7 +1393,7 @@ export function VirtualMachines() {
</div> </div>
)} )}
{/* GPU Passthrough */} {/* GPU Passthrough with proper keys */}
{vmDetails.hardware_info.gpu_passthrough && {vmDetails.hardware_info.gpu_passthrough &&
vmDetails.hardware_info.gpu_passthrough.length > 0 && ( vmDetails.hardware_info.gpu_passthrough.length > 0 && (
<div> <div>
@@ -1396,7 +1401,7 @@ export function VirtualMachines() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => ( {vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
<Badge <Badge
key={index} key={`gpu-${selectedVM.vmid}-${index}-${gpu.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
variant="outline" variant="outline"
className={ className={
gpu.includes("NVIDIA") gpu.includes("NVIDIA")
@@ -1411,7 +1416,7 @@ export function VirtualMachines() {
</div> </div>
)} )}
{/* Other Hardware Devices */} {/* Hardware Devices with proper keys */}
{vmDetails.hardware_info.devices && {vmDetails.hardware_info.devices &&
vmDetails.hardware_info.devices.length > 0 && ( vmDetails.hardware_info.devices.length > 0 && (
<div> <div>
@@ -1419,7 +1424,7 @@ export function VirtualMachines() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.devices.map((device, index) => ( {vmDetails.hardware_info.devices.map((device, index) => (
<Badge <Badge
key={index} key={`device-${selectedVM.vmid}-${index}-${device.replace(/[^a-zA-Z0-9]/g, "-").substring(0, 30)}`}
variant="outline" variant="outline"
className="bg-blue-500/10 text-blue-500 border-blue-500/20" className="bg-blue-500/10 text-blue-500 border-blue-500/20"
> >
@@ -1541,7 +1546,7 @@ export function VirtualMachines() {
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{vmDetails.config.rootfs && ( {vmDetails.config.rootfs && (
<div> <div key="rootfs">
<div className="text-xs text-muted-foreground mb-1">Root Filesystem</div> <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"> <div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.rootfs} {vmDetails.config.rootfs}
@@ -1549,15 +1554,16 @@ export function VirtualMachines() {
</div> </div>
)} )}
{vmDetails.config.scsihw && ( {vmDetails.config.scsihw && (
<div> <div key="scsihw">
<div className="text-xs text-muted-foreground mb-1">SCSI Controller</div> <div className="text-xs text-muted-foreground mb-1">SCSI Controller</div>
<div className="font-medium text-foreground">{vmDetails.config.scsihw}</div> <div className="font-medium text-foreground">{vmDetails.config.scsihw}</div>
</div> </div>
)} )}
{/* Disk Storage with proper keys */}
{Object.keys(vmDetails.config) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/)) .filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
.map((diskKey) => ( .map((diskKey) => (
<div key={diskKey}> <div key={`disk-${selectedVM.vmid}-${diskKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
{diskKey.toUpperCase().replace(/(\d+)/, " $1")} {diskKey.toUpperCase().replace(/(\d+)/, " $1")}
</div> </div>
@@ -1567,7 +1573,7 @@ export function VirtualMachines() {
</div> </div>
))} ))}
{vmDetails.config.efidisk0 && ( {vmDetails.config.efidisk0 && (
<div> <div key="efidisk0">
<div className="text-xs text-muted-foreground mb-1">EFI Disk</div> <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"> <div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.efidisk0} {vmDetails.config.efidisk0}
@@ -1575,18 +1581,18 @@ export function VirtualMachines() {
</div> </div>
)} )}
{vmDetails.config.tpmstate0 && ( {vmDetails.config.tpmstate0 && (
<div> <div key="tpmstate0">
<div className="text-xs text-muted-foreground mb-1">TPM State</div> <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"> <div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.tpmstate0} {vmDetails.config.tpmstate0}
</div> </div>
</div> </div>
)} )}
{/* Mount points for LXC */} {/* Mount Points with proper keys */}
{Object.keys(vmDetails.config) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^mp\d+$/)) .filter((key) => key.match(/^mp\d+$/))
.map((mpKey) => ( .map((mpKey) => (
<div key={mpKey}> <div key={`mp-${selectedVM.vmid}-${mpKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
Mount Point {mpKey.replace("mp", "")} Mount Point {mpKey.replace("mp", "")}
</div> </div>
@@ -1604,10 +1610,11 @@ export function VirtualMachines() {
Network Network
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
{/* Network Interfaces with proper keys */}
{Object.keys(vmDetails.config) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^net\d+$/)) .filter((key) => key.match(/^net\d+$/))
.map((netKey) => ( .map((netKey) => (
<div key={netKey}> <div key={`net-${selectedVM.vmid}-${netKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
Network Interface {netKey.replace("net", "")} Network Interface {netKey.replace("net", "")}
</div> </div>
@@ -1645,7 +1652,7 @@ export function VirtualMachines() {
</div> </div>
</div> </div>
{/* PCI Devices Section */} {/* PCI Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && ( {Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
<div> <div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide"> <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) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^hostpci\d+$/)) .filter((key) => key.match(/^hostpci\d+$/))
.map((pciKey) => ( .map((pciKey) => (
<div key={pciKey}> <div key={`pci-${selectedVM.vmid}-${pciKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
{pciKey.toUpperCase().replace(/(\d+)/, " $1")} {pciKey.toUpperCase().replace(/(\d+)/, " $1")}
</div> </div>
@@ -1668,7 +1675,7 @@ export function VirtualMachines() {
</div> </div>
)} )}
{/* USB Devices Section */} {/* USB Devices with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && ( {Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
<div> <div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide"> <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) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^usb\d+$/)) .filter((key) => key.match(/^usb\d+$/))
.map((usbKey) => ( .map((usbKey) => (
<div key={usbKey}> <div key={`usb-${selectedVM.vmid}-${usbKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
{usbKey.toUpperCase().replace(/(\d+)/, " $1")} {usbKey.toUpperCase().replace(/(\d+)/, " $1")}
</div> </div>
@@ -1691,7 +1698,7 @@ export function VirtualMachines() {
</div> </div>
)} )}
{/* Serial Devices Section */} {/* Serial Ports with proper keys */}
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && ( {Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
<div> <div>
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide"> <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) {Object.keys(vmDetails.config)
.filter((key) => key.match(/^serial\d+$/)) .filter((key) => key.match(/^serial\d+$/))
.map((serialKey) => ( .map((serialKey) => (
<div key={serialKey}> <div key={`serial-${selectedVM.vmid}-${serialKey}`}>
<div className="text-xs text-muted-foreground mb-1"> <div className="text-xs text-muted-foreground mb-1">
{serialKey.toUpperCase().replace(/(\d+)/, " $1")} {serialKey.toUpperCase().replace(/(\d+)/, " $1")}
</div> </div>
@@ -1713,91 +1720,6 @@ export function VirtualMachines() {
</div> </div>
</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> </div>
)} )}
</CardContent> </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 - Password hashing and verification
- JWT token generation and validation - JWT token generation and validation
- Auth status checking - Auth status checking
- Two-Factor Authentication (2FA/TOTP)
""" """
import os import os
import json import json
import hashlib import hashlib
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -20,6 +22,16 @@ except ImportError:
JWT_AVAILABLE = False JWT_AVAILABLE = False
print("Warning: PyJWT not available. Authentication features will be limited.") 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 # Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor" CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json" AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
@@ -41,8 +53,11 @@ def load_auth_config():
"enabled": bool, "enabled": bool,
"username": str, "username": str,
"password_hash": str, "password_hash": str,
"declined": bool, # True if user explicitly declined auth "declined": bool,
"configured": bool # True if auth has been set up (enabled or declined) "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(): if not AUTH_CONFIG_FILE.exists():
@@ -51,7 +66,10 @@ def load_auth_config():
"username": None, "username": None,
"password_hash": None, "password_hash": None,
"declined": False, "declined": False,
"configured": False "configured": False,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
} }
try: try:
@@ -60,6 +78,9 @@ def load_auth_config():
# Ensure all required fields exist # Ensure all required fields exist
config.setdefault("declined", False) config.setdefault("declined", False)
config.setdefault("configured", config.get("enabled", False) or config.get("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 return config
except Exception as e: except Exception as e:
print(f"Error loading auth config: {e}") print(f"Error loading auth config: {e}")
@@ -68,7 +89,10 @@ def load_auth_config():
"username": None, "username": None,
"password_hash": None, "password_hash": None,
"declined": False, "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, "auth_configured": bool,
"declined": bool, "declined": bool,
"username": str or None, "username": str or None,
"authenticated": bool "authenticated": bool,
"totp_enabled": bool # 2FA status
} }
""" """
config = load_auth_config() config = load_auth_config()
return { return {
"auth_enabled": config.get("enabled", False), "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), "declined": config.get("declined", False),
"username": config.get("username") if config.get("enabled") else None, "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, "username": username,
"password_hash": hash_password(password), "password_hash": hash_password(password),
"declined": False, "declined": False,
"configured": True "configured": True,
"totp_enabled": False,
"totp_secret": None,
"backup_codes": []
} }
if save_auth_config(config): if save_auth_config(config):
@@ -190,6 +219,9 @@ def decline_auth():
config["configured"] = True config["configured"] = True
config["username"] = None config["username"] = None
config["password_hash"] = None config["password_hash"] = None
config["totp_enabled"] = False
config["totp_secret"] = None
config["backup_codes"] = []
if save_auth_config(config): if save_auth_config(config):
return True, "Authentication declined" return True, "Authentication declined"
@@ -204,8 +236,13 @@ def disable_auth():
""" """
config = load_auth_config() config = load_auth_config()
config["enabled"] = False config["enabled"] = False
# Keep configured=True and don't set declined=True config["username"] = None
# This allows re-enabling without showing the setup modal again 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): if save_auth_config(config):
return True, "Authentication disabled" return True, "Authentication disabled"
@@ -256,24 +293,212 @@ def change_password(old_password, new_password):
return False, "Failed to save 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 Generate a QR code for TOTP setup
Returns (success: bool, token: str or None, message: str) 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() config = load_auth_config()
if not config.get("enabled"): 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"): 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", "")): 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) token = generate_token(username)
if token: if token:
return True, token, "Authentication successful" return True, token, False, "Authentication successful"
else: 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 \ psutil \
requests \ requests \
PyJWT \ PyJWT \
pyotp \
segno \
googletrans==4.0.0-rc1 \ googletrans==4.0.0-rc1 \
httpx==0.13.3 \ httpx==0.13.3 \
httpcore==0.9.1 \ httpcore==0.9.1 \

View File

@@ -64,11 +64,14 @@ def auth_login():
data = request.json data = request.json
username = data.get('username') username = data.get('username')
password = data.get('password') 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: if success:
return jsonify({"success": True, "token": token, "message": message}) return jsonify({"success": True, "token": token, "message": message})
elif requires_totp:
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else: else:
return jsonify({"success": False, "message": message}), 401 return jsonify({"success": False, "message": message}), 401
except Exception as e: except Exception as e:
@@ -93,6 +96,10 @@ def auth_enable():
def auth_disable(): def auth_disable():
"""Disable authentication""" """Disable authentication"""
try: 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() success, message = auth_manager.disable_auth()
if success: if success:
@@ -119,3 +126,95 @@ def auth_change_password():
return jsonify({"success": False, "message": message}), 400 return jsonify({"success": False, "message": message}), 400
except Exception as e: except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500 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) return jsonify(details)
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 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 import re
match = re.match(r'(nvme\d+)n\d+', disk_name) match = re.match(r'(nvme\d+)n\d+', disk_name)
if not match: 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 return pcie_info
controller = match.group(1) # nvme0n1 -> nvme0 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 # Path to PCIe device in sysfs
sys_path = f'/sys/class/nvme/{controller}/device' 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): if os.path.exists(sys_path):
try: try:
pci_address = os.path.basename(os.readlink(sys_path)) 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 # Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address], result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=5)
if result.returncode == 0: 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'): for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status # Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line: if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}") # print(f"[v0] Found LnkSta: {line}")
pass
# Example: "LnkSta: Speed 8GT/s, Width x4" # Example: "LnkSta: Speed 8GT/s, Width x4"
if 'Speed' in line: if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', 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' pcie_info['pcie_gen'] = '4.0'
else: else:
pcie_info['pcie_gen'] = '5.0' 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: if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line) width_match = re.search(r'Width\s+x(\d+)', line)
if width_match: if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}' 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 # Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line: elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}") # print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line: if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match: if speed_match:
@@ -1015,39 +1024,48 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_max_gen'] = '4.0' pcie_info['pcie_max_gen'] = '4.0'
else: else:
pcie_info['pcie_max_gen'] = '5.0' 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: if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line) width_match = re.search(r'Width\s+x(\d+)', line)
if width_match: if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}' 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: 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: 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 import traceback
traceback.print_exc() traceback.print_exc()
else: 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' 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): if os.path.exists(alt_sys_path):
try: try:
# Get PCI address from the alternative path # Get PCI address from the alternative path
pci_address = os.path.basename(os.readlink(alt_sys_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 # Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address], result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=5)
if result.returncode == 0: 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'): for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status # Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line: if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}") # print(f"[v0] Found LnkSta: {line}")
pass
if 'Speed' in line: if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match: if speed_match:
@@ -1062,17 +1080,20 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_gen'] = '4.0' pcie_info['pcie_gen'] = '4.0'
else: else:
pcie_info['pcie_gen'] = '5.0' 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: if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line) width_match = re.search(r'Width\s+x(\d+)', line)
if width_match: if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}' 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 # Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line: elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}") # print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line: if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line) speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match: if speed_match:
@@ -1087,26 +1108,32 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_max_gen'] = '4.0' pcie_info['pcie_max_gen'] = '4.0'
else: else:
pcie_info['pcie_max_gen'] = '5.0' 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: if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line) width_match = re.search(r'Width\s+x(\d+)', line)
if width_match: if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}' 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: 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: 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 import traceback
traceback.print_exc() traceback.print_exc()
except Exception as e: 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 import traceback
traceback.print_exc() 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 return pcie_info
# get_pcie_link_speed function definition ends here # get_pcie_link_speed function definition ends here
@@ -5397,7 +5424,7 @@ def api_health():
return jsonify({ return jsonify({
'status': 'healthy', 'status': 'healthy',
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'version': '1.0.0' 'version': '1.0.1'
}) })
@app.route('/api/prometheus', methods=['GET']) @app.route('/api/prometheus', methods=['GET'])
@@ -5655,57 +5682,6 @@ def api_prometheus():
traceback.print_exc() traceback.print_exc()
return f'# Error generating metrics: {str(e)}\n', 500, {'Content-Type': 'text/plain; charset=utf-8'} 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']) @app.route('/api/info', methods=['GET'])
def api_info(): 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" HN="OpenMediaVault"
;; ;;
5) 5)
ISO_NAME="XigmaNAS-13.3.0.5" ISO_NAME="XigmaNAS-14.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_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-13.3.0.5.10153.iso" ISO_FILE="XigmaNAS-x64-LiveCD-14.3.0.5.10566.iso"
ISO_PATH="$ISO_DIR/$ISO_FILE" ISO_PATH="$ISO_DIR/$ISO_FILE"
HN="XigmaNAS" HN="XigmaNAS"
;; ;;