Updete AppImage

This commit is contained in:
MacRimi
2025-11-07 12:17:10 +01:00
parent a94000e114
commit ee57797890
9 changed files with 558 additions and 174 deletions

View File

@@ -21,6 +21,7 @@ import {
import useSWR from "swr" import useSWR from "swr"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { type HardwareData, type GPU, type PCIDevice, type StorageDevice, fetcher } from "../types/hardware" import { type HardwareData, type GPU, type PCIDevice, type StorageDevice, fetcher } from "../types/hardware"
import { usePollingConfig } from "@/lib/polling-config"
const parseLsblkSize = (sizeStr: string | undefined): number => { const parseLsblkSize = (sizeStr: string | undefined): number => {
if (!sizeStr) return 0 if (!sizeStr) return 0
@@ -163,14 +164,38 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
} }
export default function Hardware() { export default function Hardware() {
const { intervals } = usePollingConfig()
// Static data (loaded once on mount): system info, memory, PCI, network/storage summaries
const { const {
data: hardwareData, data: staticHardwareData,
error, error: staticError,
isLoading, isLoading: staticLoading,
} = useSWR<HardwareData>("/api/hardware", fetcher, { } = useSWR<HardwareData>("/api/hardware", fetcher, {
refreshInterval: 5000, refreshInterval: 0, // Never refresh automatically - only load once
revalidateOnFocus: false,
revalidateOnReconnect: false,
}) })
// Dynamic data (temperatures only) - polls at configured interval
const { data: temperatureData, error: tempError } = useSWR<{ temperatures: any[] }>(
"/api/hardware/temperatures",
fetcher,
{
refreshInterval: intervals.hardware,
},
)
const hardwareData = staticHardwareData
? {
...staticHardwareData,
temperatures: temperatureData?.temperatures || staticHardwareData.temperatures || [],
}
: null
const error = staticError || tempError
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

@@ -73,6 +73,10 @@ interface NetworkInterface {
vm_status?: string vm_status?: string
} }
interface NetworkMetricsProps {
isActive?: boolean
}
const getInterfaceTypeBadge = (type: string) => { const getInterfaceTypeBadge = (type: string) => {
switch (type) { switch (type) {
case "physical": case "physical":
@@ -143,15 +147,16 @@ const fetcher = async (url: string): Promise<NetworkData> => {
return response.json() return response.json()
} }
export function NetworkMetrics() { export function NetworkMetrics({ isActive = true }: NetworkMetricsProps) {
const { const {
data: networkData, data: networkData,
error, error,
isLoading, isLoading,
} = useSWR<NetworkData>("/api/network", fetcher, { } = useSWR<NetworkData>("/api/network", fetcher, {
refreshInterval: 60000, // Refresh every 60 seconds refreshInterval: isActive ? 60000 : 0, // Refresh every 60 seconds only if active, otherwise pause
revalidateOnFocus: false, revalidateOnFocus: false,
revalidateOnReconnect: true, revalidateOnReconnect: true,
isPaused: () => !isActive,
}) })
const [selectedInterface, setSelectedInterface] = useState<NetworkInterface | null>(null) const [selectedInterface, setSelectedInterface] = useState<NetworkInterface | null>(null)
@@ -166,10 +171,15 @@ export function NetworkMetrics() {
revalidateOnReconnect: true, revalidateOnReconnect: true,
}) })
const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, { const { data: interfaceHistoricalData } = useSWR<any>(
refreshInterval: 30000, isActive ? `/api/node/metrics?timeframe=${timeframe}` : null,
revalidateOnFocus: false, fetcher,
}) {
refreshInterval: isActive ? 30000 : 0,
revalidateOnFocus: false,
isPaused: () => !isActive,
},
)
if (isLoading) { if (isLoading) {
return ( return (

View File

@@ -16,6 +16,7 @@ interface NetworkTrafficChartProps {
interfaceName?: string interfaceName?: string
onTotalsCalculated?: (totals: { received: number; sent: number }) => void onTotalsCalculated?: (totals: { received: number; sent: number }) => void
refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos) refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos)
isActive?: boolean
} }
const CustomNetworkTooltip = ({ active, payload, label }: any) => { const CustomNetworkTooltip = ({ active, payload, label }: any) => {
@@ -43,6 +44,7 @@ export function NetworkTrafficChart({
interfaceName, interfaceName,
onTotalsCalculated, onTotalsCalculated,
refreshInterval = 60000, refreshInterval = 60000,
isActive = true,
}: NetworkTrafficChartProps) { }: NetworkTrafficChartProps) {
const [data, setData] = useState<NetworkMetricsData[]>([]) const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -59,16 +61,20 @@ export function NetworkTrafficChart({
}, [timeframe, interfaceName]) }, [timeframe, interfaceName])
useEffect(() => { useEffect(() => {
if (refreshInterval > 0) { if (refreshInterval > 0 && isActive) {
const interval = setInterval(() => { const interval = setInterval(() => {
fetchMetrics() fetchMetrics()
}, refreshInterval) }, refreshInterval)
return () => clearInterval(interval) return () => clearInterval(interval)
} }
}, [timeframe, interfaceName, refreshInterval]) }, [timeframe, interfaceName, refreshInterval, isActive])
const fetchMetrics = async () => { const fetchMetrics = async () => {
if (!isActive) {
return
}
if (isInitialLoad) { if (isInitialLoad) {
setLoading(true) setLoading(true)
} }

View File

@@ -78,6 +78,12 @@ export function NodeMetricsCharts() {
useEffect(() => { useEffect(() => {
console.log("[v0] NodeMetricsCharts component mounted") console.log("[v0] NodeMetricsCharts component mounted")
fetchMetrics() fetchMetrics()
const interval = setInterval(() => {
fetchMetrics()
}, 60000) // 60 seconds instead of 30 to reduce server load
return () => clearInterval(interval)
}, [timeframe]) }, [timeframe])
const fetchMetrics = async () => { const fetchMetrics = async () => {

View File

@@ -13,6 +13,9 @@ import { SystemLogs } from "./system-logs"
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"
import { usePollingConfig, INTERVAL_OPTIONS } from "@/lib/polling-config"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { import {
RefreshCw, RefreshCw,
AlertTriangle, AlertTriangle,
@@ -26,6 +29,7 @@ import {
Box, Box,
Cpu, Cpu,
FileText, FileText,
Settings,
} 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"
@@ -65,6 +69,7 @@ export function ProxmoxDashboard() {
const [showNavigation, setShowNavigation] = useState(true) const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0) const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false) const [showHealthModal, setShowHealthModal] = useState(false)
const { intervals, updateInterval } = usePollingConfig()
const fetchSystemData = useCallback(async () => { const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...") console.log("[v0] Fetching system data from Flask server...")
@@ -127,8 +132,12 @@ export function ProxmoxDashboard() {
}, []) }, [])
useEffect(() => { useEffect(() => {
// Only fetch if we actually need the data (always for header)
fetchSystemData() fetchSystemData()
const interval = setInterval(fetchSystemData, 10000)
// Poll every 30 seconds for header info (less frequent since it's just for display)
const interval = setInterval(fetchSystemData, 30000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [fetchSystemData]) }, [fetchSystemData])
@@ -216,6 +225,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"
} }
@@ -362,7 +373,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 +410,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 +524,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"
: ""
}`}
>
<Settings className="h-5 w-5" />
<span>Settings</span>
</Button>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -517,7 +549,7 @@ export function ProxmoxDashboard() {
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6"> <div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
<SystemOverview key={`overview-${componentKey}`} /> <SystemOverview key={`overview-${componentKey}`} isActive={activeTab === "overview"} />
</TabsContent> </TabsContent>
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
@@ -525,7 +557,7 @@ export function ProxmoxDashboard() {
</TabsContent> </TabsContent>
<TabsContent value="network" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="network" className="space-y-4 md:space-y-6 mt-0">
<NetworkMetrics key={`network-${componentKey}`} /> <NetworkMetrics key={`network-${componentKey}`} isActive={activeTab === "network"} />
</TabsContent> </TabsContent>
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
@@ -539,6 +571,183 @@ 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">
<div className="grid gap-4 md:gap-6">
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-2xl font-semibold mb-4 flex items-center gap-2">
<Settings className="h-6 w-6" />
Settings
</h2>
<p className="text-muted-foreground mb-6">
Configure your ProxMenux Monitor preferences and system settings.
</p>
<div className="space-y-6">
<div className="border-t border-border pt-4">
<h3 className="text-lg font-medium mb-2">Appearance</h3>
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium">Theme</p>
<p className="text-sm text-muted-foreground">Choose your preferred color scheme</p>
</div>
<ThemeToggle />
</div>
</div>
<div className="border-t border-border pt-4">
<h3 className="text-lg font-medium mb-2">System Information</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between py-2">
<span className="text-muted-foreground">Version:</span>
<span className="font-medium">ProxMenux Monitor v1.0.0</span>
</div>
<div className="flex justify-between py-2">
<span className="text-muted-foreground">Server:</span>
<span className="font-medium">{systemStatus.serverName}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-muted-foreground">Node ID:</span>
<span className="font-medium">{systemStatus.nodeId}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-muted-foreground">Status:</span>
<Badge variant="outline" className={statusColor}>
{statusIcon}
<span className="ml-1 capitalize">{systemStatus.status}</span>
</Badge>
</div>
</div>
</div>
<div className="border-t border-border pt-4">
<h3 className="text-lg font-medium mb-2">Polling Intervals</h3>
<p className="text-sm text-muted-foreground mb-4">
Configure how frequently each section updates its data. Lower values provide more real-time data
but increase server load.
</p>
<div className="space-y-4">
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="storage-interval" className="font-medium">
Storage
</Label>
<p className="text-sm text-muted-foreground">Update frequency for storage metrics</p>
</div>
<Select
value={intervals.storage.toString()}
onValueChange={(value) => updateInterval("storage", Number.parseInt(value))}
>
<SelectTrigger id="storage-interval" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="network-interval" className="font-medium">
Network
</Label>
<p className="text-sm text-muted-foreground">Update frequency for network metrics</p>
</div>
<Select
value={intervals.network.toString()}
onValueChange={(value) => updateInterval("network", Number.parseInt(value))}
>
<SelectTrigger id="network-interval" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="vms-interval" className="font-medium">
VMs & LXCs
</Label>
<p className="text-sm text-muted-foreground">Update frequency for VM/LXC data</p>
</div>
<Select
value={intervals.vms.toString()}
onValueChange={(value) => updateInterval("vms", Number.parseInt(value))}
>
<SelectTrigger id="vms-interval" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="hardware-interval" className="font-medium">
Hardware
</Label>
<p className="text-sm text-muted-foreground">Update frequency for temperature sensors only</p>
</div>
<Select
value={intervals.hardware.toString()}
onValueChange={(value) => updateInterval("hardware", Number.parseInt(value))}
>
<SelectTrigger id="hardware-interval" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="mt-4 text-xs text-muted-foreground bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
<strong>Note:</strong> Hardware static information (System Info, Memory Modules, PCI Devices,
Network/Storage Summaries) is loaded only once when entering the Hardware page and does not
refresh automatically.
</p>
</div>
<div className="border-t border-border pt-4">
<h3 className="text-lg font-medium mb-2">About</h3>
<p className="text-sm text-muted-foreground mb-4">
ProxMenux Monitor is a comprehensive dashboard for monitoring and managing Proxmox VE systems.
</p>
<a
href="https://ko-fi.com/macrimi"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-blue-500 hover:text-blue-600 hover:underline transition-colors"
>
Support and contribute to the project
</a>
</div>
</div>
</div>
</div>
</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">

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
@@ -433,22 +433,35 @@ export function SystemLogs() {
return String(value).toLowerCase() return String(value).toLowerCase()
} }
const logsOnly: CombinedLogEntry[] = logs const memoizedLogs = useMemo(() => logs, [logs])
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })) const memoizedEvents = useMemo(() => events, [events])
.sort((a, b) => b.sortTimestamp - a.sortTimestamp) const memoizedBackups = useMemo(() => backups, [backups])
const memoizedNotifications = useMemo(() => notifications, [notifications])
const eventsOnly: CombinedLogEntry[] = events const logsOnly: CombinedLogEntry[] = useMemo(
.map((event) => ({ () =>
timestamp: event.starttime, memoizedLogs
level: event.level, .map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
service: event.type, .sort((a, b) => b.sortTimestamp - a.sortTimestamp),
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`, [memoizedLogs],
source: `Node: ${event.node} • User: ${event.user}`, )
isEvent: true,
eventData: event, const eventsOnly: CombinedLogEntry[] = useMemo(
sortTimestamp: new Date(event.starttime).getTime(), () =>
})) memoizedEvents
.sort((a, b) => b.sortTimestamp - a.sortTimestamp) .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],
)
const filteredLogsOnly = logsOnly.filter((log) => { const filteredLogsOnly = logsOnly.filter((log) => {
const message = log.message || "" const message = log.message || ""
@@ -479,32 +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],
)
const filteredCombinedLogs = combinedLogs.filter((log) => { const filteredCombinedLogs = useMemo(
const message = log.message || "" () =>
const service = log.service || "" combinedLogs.filter((log) => {
const searchTermLower = safeToLowerCase(searchTerm) const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch = const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower) safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
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
}) }),
[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)
@@ -605,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")) {
@@ -930,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>
))} ))}
@@ -947,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">
@@ -1052,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">
@@ -1105,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

@@ -96,6 +96,10 @@ interface ProxmoxStorageData {
}> }>
} }
interface SystemOverviewProps {
isActive?: boolean
}
const fetchSystemData = async (): Promise<SystemData | null> => { const fetchSystemData = async (): Promise<SystemData | null> => {
try { try {
const apiUrl = getApiUrl("/api/system") const apiUrl = getApiUrl("/api/system")
@@ -219,7 +223,7 @@ const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> =>
} }
} }
export function SystemOverview() { export function SystemOverview({ isActive = true }: SystemOverviewProps) {
const [systemData, setSystemData] = useState<SystemData | null>(null) const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([]) const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null) const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -231,6 +235,10 @@ export function SystemOverview() {
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
useEffect(() => { useEffect(() => {
if (!isActive) {
return
}
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true) setLoading(true)
@@ -253,6 +261,7 @@ export function SystemOverview() {
} }
} }
// Initial fetch
fetchData() fetchData()
const systemInterval = setInterval(() => { const systemInterval = setInterval(() => {
@@ -261,10 +270,11 @@ export function SystemOverview() {
}) })
}, 10000) }, 10000)
// Clean up interval when component unmounts or becomes inactive
return () => { return () => {
clearInterval(systemInterval) clearInterval(systemInterval)
} }
}, []) }, [isActive]) // Add isActive as dependency
useEffect(() => { useEffect(() => {
const fetchVMs = async () => { const fetchVMs = async () => {
@@ -721,7 +731,11 @@ export function SystemOverview() {
</div> </div>
<div className="pt-3 border-t border-border"> <div className="pt-3 border-t border-border">
<NetworkTrafficChart timeframe={networkTimeframe} onTotalsCalculated={setNetworkTotals} /> <NetworkTrafficChart
timeframe={networkTimeframe}
onTotalsCalculated={setNetworkTotals}
isActive={isActive}
/>
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -350,7 +350,7 @@ export function VirtualMachines() {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ action }), body: JSON.JSON.stringify({ action }),
}) })
if (response.ok) { if (response.ok) {
@@ -451,7 +451,7 @@ export function VirtualMachines() {
"/api/system", "/api/system",
fetcher, fetcher,
{ {
refreshInterval: 30000, refreshInterval: 60000, // Changed from 30s to 60s
revalidateOnFocus: false, revalidateOnFocus: false,
}, },
) )
@@ -1275,7 +1275,7 @@ export function VirtualMachines() {
{/* Real IPs (green, without "Real" label) */} {/* 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={`ip-real-${selectedVM.vmid}-${ip}-${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"
> >
@@ -1285,7 +1285,7 @@ export function VirtualMachines() {
{/* Docker bridge IPs (yellow, with "Bridge" label) */} {/* 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={`ip-docker-${selectedVM.vmid}-${ip}-${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"
> >
@@ -1403,7 +1403,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.substring(0, 20)}`}
variant="outline" variant="outline"
className={ className={
gpu.includes("NVIDIA") gpu.includes("NVIDIA")
@@ -1426,7 +1426,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.substring(0, 20)}`}
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"
> >

View File

@@ -0,0 +1,74 @@
"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(() => {
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 }
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) {
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 },
]