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 { useState, useEffect } from "react"
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 => {
if (!sizeStr) return 0
@@ -163,14 +164,38 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
}
export default function Hardware() {
const { intervals } = usePollingConfig()
// Static data (loaded once on mount): system info, memory, PCI, network/storage summaries
const {
data: hardwareData,
error,
isLoading,
data: staticHardwareData,
error: staticError,
isLoading: staticLoading,
} = 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(() => {
if (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
}
interface NetworkMetricsProps {
isActive?: boolean
}
const getInterfaceTypeBadge = (type: string) => {
switch (type) {
case "physical":
@@ -143,15 +147,16 @@ const fetcher = async (url: string): Promise<NetworkData> => {
return response.json()
}
export function NetworkMetrics() {
export function NetworkMetrics({ isActive = true }: NetworkMetricsProps) {
const {
data: networkData,
error,
isLoading,
} = 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,
revalidateOnReconnect: true,
isPaused: () => !isActive,
})
const [selectedInterface, setSelectedInterface] = useState<NetworkInterface | null>(null)
@@ -166,10 +171,15 @@ export function NetworkMetrics() {
revalidateOnReconnect: true,
})
const { data: interfaceHistoricalData } = useSWR<any>(`/api/node/metrics?timeframe=${timeframe}`, fetcher, {
refreshInterval: 30000,
revalidateOnFocus: false,
})
const { data: interfaceHistoricalData } = useSWR<any>(
isActive ? `/api/node/metrics?timeframe=${timeframe}` : null,
fetcher,
{
refreshInterval: isActive ? 30000 : 0,
revalidateOnFocus: false,
isPaused: () => !isActive,
},
)
if (isLoading) {
return (

View File

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

View File

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

View File

@@ -13,6 +13,9 @@ import { SystemLogs } from "./system-logs"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
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 {
RefreshCw,
AlertTriangle,
@@ -26,6 +29,7 @@ import {
Box,
Cpu,
FileText,
Settings,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -65,6 +69,7 @@ export function ProxmoxDashboard() {
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
const { intervals, updateInterval } = usePollingConfig()
const fetchSystemData = useCallback(async () => {
console.log("[v0] Fetching system data from Flask server...")
@@ -127,8 +132,12 @@ export function ProxmoxDashboard() {
}, [])
useEffect(() => {
// Only fetch if we actually need the data (always for header)
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)
}, [fetchSystemData])
@@ -216,6 +225,8 @@ export function ProxmoxDashboard() {
return "Hardware"
case "logs":
return "System Logs"
case "settings":
return "Settings"
default:
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">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
<TabsList className="hidden md:grid w-full grid-cols-6 bg-card border border-border">
<TabsList className="hidden md:grid w-full grid-cols-7 bg-card border border-border">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
@@ -399,6 +410,12 @@ export function ProxmoxDashboard() {
>
System Logs
</TabsTrigger>
<TabsTrigger
value="settings"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Settings
</TabsTrigger>
</TabsList>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
@@ -507,6 +524,21 @@ export function ProxmoxDashboard() {
<FileText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("settings")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "settings"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Settings className="h-5 w-5" />
<span>Settings</span>
</Button>
</div>
</SheetContent>
</Sheet>
@@ -517,7 +549,7 @@ export function ProxmoxDashboard() {
<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">
<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 value="storage" className="space-y-4 md:space-y-6 mt-0">
@@ -525,7 +557,7 @@ export function ProxmoxDashboard() {
</TabsContent>
<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 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">
<SystemLogs key={`logs-${componentKey}`} />
</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>
<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,
Terminal,
} from "lucide-react"
import { useState, useEffect } from "react"
import { useState, useEffect, useMemo } from "react"
interface Log {
timestamp: string
@@ -433,22 +433,35 @@ export function SystemLogs() {
return String(value).toLowerCase()
}
const logsOnly: CombinedLogEntry[] = logs
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const memoizedLogs = useMemo(() => logs, [logs])
const memoizedEvents = useMemo(() => events, [events])
const memoizedBackups = useMemo(() => backups, [backups])
const memoizedNotifications = useMemo(() => notifications, [notifications])
const eventsOnly: CombinedLogEntry[] = events
.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
}))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const logsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedLogs
.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() }))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs],
)
const eventsOnly: CombinedLogEntry[] = useMemo(
() =>
memoizedEvents
.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
}))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedEvents],
)
const filteredLogsOnly = logsOnly.filter((log) => {
const message = log.message || ""
@@ -479,32 +492,40 @@ export function SystemLogs() {
const displayedLogsOnly = filteredLogsOnly.slice(0, displayedLogsCount)
const displayedEventsOnly = filteredEventsOnly.slice(0, displayedLogsCount)
const combinedLogs: CombinedLogEntry[] = [
...logs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...events.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
})),
].sort((a, b) => b.sortTimestamp - a.sortTimestamp) // Sort by timestamp descending
const combinedLogs: CombinedLogEntry[] = useMemo(
() =>
[
...memoizedLogs.map((log) => ({ ...log, isEvent: false, sortTimestamp: new Date(log.timestamp).getTime() })),
...memoizedEvents.map((event) => ({
timestamp: event.starttime,
level: event.level,
service: event.type,
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
source: `Node: ${event.node} • User: ${event.user}`,
isEvent: true,
eventData: event,
sortTimestamp: new Date(event.starttime).getTime(),
})),
].sort((a, b) => b.sortTimestamp - a.sortTimestamp),
[memoizedLogs, memoizedEvents],
)
const filteredCombinedLogs = combinedLogs.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const filteredCombinedLogs = useMemo(
() =>
combinedLogs.filter((log) => {
const message = log.message || ""
const service = log.service || ""
const searchTermLower = safeToLowerCase(searchTerm)
const matchesSearch =
safeToLowerCase(message).includes(searchTermLower) || safeToLowerCase(service).includes(searchTermLower)
const matchesLevel = levelFilter === "all" || log.level === levelFilter
const matchesService = serviceFilter === "all" || log.service === serviceFilter
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
})
return matchesSearch && matchesLevel && matchesService
}),
[combinedLogs, searchTerm, levelFilter, serviceFilter],
)
// CHANGE: Re-assigning displayedLogs to use the filteredCombinedLogs
const displayedLogs = filteredCombinedLogs.slice(0, displayedLogsCount)
@@ -605,7 +626,7 @@ export function SystemLogs() {
info: logs.filter((log) => ["info", "notice", "debug"].includes(log.level)).length,
}
const uniqueServices = [...new Set(logs.map((log) => log.service))]
const uniqueServices = useMemo(() => [...new Set(memoizedLogs.map((log) => log.service))], [memoizedLogs])
const getBackupType = (volid: string): "vm" | "lxc" => {
if (volid.includes("/vm/") || volid.includes("vzdump-qemu")) {
@@ -930,9 +951,11 @@ export function SystemLogs() {
<SelectValue placeholder="Filter by service" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Services</SelectItem>
{uniqueServices.slice(0, 20).map((service) => (
<SelectItem key={service} value={service}>
<SelectItem key="service-all" value="all">
All Services
</SelectItem>
{uniqueServices.slice(0, 20).map((service, idx) => (
<SelectItem key={`service-${service}-${idx}`} value={service}>
{service}
</SelectItem>
))}
@@ -947,51 +970,59 @@ export function SystemLogs() {
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-x-hidden">
<div className="space-y-2 p-4 w-full box-border">
{displayedLogs.map((log, index) => (
<div
key={index}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
onClick={() => {
if (log.eventData) {
setSelectedEvent(log.eventData)
setIsEventModalOpen(true)
} else {
setSelectedLog(log as SystemLog)
setIsLogModalOpen(true)
}
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
</Badge>
)}
</div>
{displayedLogs.map((log, index) => {
// Generate a more stable unique key
const timestampMs = new Date(log.timestamp).getTime()
const uniqueKey = log.eventData
? `event-${log.eventData.upid.replace(/:/g, "-")}-${timestampMs}`
: `log-${timestampMs}-${log.service?.substring(0, 10) || "unknown"}-${log.pid || "nopid"}-${index}`
<div className="flex-1 min-w-0 overflow-hidden box-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
{log.timestamp}
return (
<div
key={uniqueKey}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
onClick={() => {
if (log.eventData) {
setSelectedEvent(log.eventData)
setIsEventModalOpen(true)
} else {
setSelectedLog(log as SystemLog)
setIsLogModalOpen(true)
}
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getLevelColor(log.level)}>
{getLevelIcon(log.level)}
{log.level.toUpperCase()}
</Badge>
{log.eventData && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
<Activity className="h-3 w-3 mr-1" />
EVENT
</Badge>
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden box-border">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
{log.timestamp}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{log.message}
</div>
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
{log.source}
{log.pid && ` • PID: ${log.pid}`}
{log.hostname && ` • Host: ${log.hostname}`}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{log.message}
</div>
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
{log.source}
{log.pid && ` • PID: ${log.pid}`}
{log.hostname && ` • Host: ${log.hostname}`}
</div>
</div>
</div>
))}
)
})}
{displayedLogs.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
@@ -1052,44 +1083,48 @@ export function SystemLogs() {
<ScrollArea className="h-[500px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{backups.map((backup, index) => (
<div
key={index}
className="flex items-start space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => {
setSelectedBackup(backup)
setIsBackupModalOpen(true)
}}
>
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-500" />
</div>
{memoizedBackups.map((backup, index) => {
const uniqueKey = `backup-${backup.volid.replace(/[/:]/g, "-")}-${backup.timestamp || index}`
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
{getBackupTypeLabel(backup.volid)}
</Badge>
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
{getBackupStorageLabel(backup.volid)}
return (
<div
key={uniqueKey}
className="flex items-start space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => {
setSelectedBackup(backup)
setIsBackupModalOpen(true)
}}
>
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className={getBackupTypeColor(backup.volid)}>
{getBackupTypeLabel(backup.volid)}
</Badge>
<Badge variant="outline" className={getBackupStorageColor(backup.volid)}>
{getBackupStorageLabel(backup.volid)}
</Badge>
</div>
<Badge
variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
>
{backup.size_human}
</Badge>
</div>
<Badge
variant="outline"
className="bg-green-500/10 text-green-500 border-green-500/20 whitespace-nowrap"
>
{backup.size_human}
</Badge>
</div>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
<div className="text-xs text-muted-foreground flex items-center">
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
<span className="truncate">{backup.created}</span>
</div>
</div>
</div>
</div>
))}
)
})}
{backups.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
@@ -1105,42 +1140,47 @@ export function SystemLogs() {
<TabsContent value="notifications" className="space-y-4">
<ScrollArea className="h-[600px] w-full rounded-md border border-border">
<div className="space-y-2 p-4">
{notifications.map((notification, index) => (
<div
key={index}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full"
onClick={() => {
setSelectedNotification(notification)
setIsNotificationModalOpen(true)
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{notification.type.toUpperCase()}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{notification.source.toUpperCase()}
</Badge>
</div>
{memoizedNotifications.map((notification, index) => {
const timestampMs = new Date(notification.timestamp).getTime()
const uniqueKey = `notification-${timestampMs}-${notification.service?.substring(0, 10) || "unknown"}-${notification.source?.substring(0, 10) || "unknown"}-${index}`
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate">{notification.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{notification.timestamp}
return (
<div
key={uniqueKey}
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full"
onClick={() => {
setSelectedNotification(notification)
setIsNotificationModalOpen(true)
}}
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{notification.type.toUpperCase()}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{notification.source.toUpperCase()}
</Badge>
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
<div className="text-sm font-medium text-foreground truncate">{notification.service}</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{notification.timestamp}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{notification.message}
</div>
<div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source}
</div>
</div>
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
{notification.message}
</div>
<div className="text-xs text-muted-foreground break-words overflow-hidden">
Service: {notification.service} Source: {notification.source}
</div>
</div>
</div>
))}
)
})}
{notifications.length === 0 && (
<div className="text-center py-8 text-muted-foreground">

View File

@@ -96,6 +96,10 @@ interface ProxmoxStorageData {
}>
}
interface SystemOverviewProps {
isActive?: boolean
}
const fetchSystemData = async (): Promise<SystemData | null> => {
try {
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 [vmData, setVmData] = useState<VMData[]>([])
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 })
useEffect(() => {
if (!isActive) {
return
}
const fetchData = async () => {
try {
setLoading(true)
@@ -253,6 +261,7 @@ export function SystemOverview() {
}
}
// Initial fetch
fetchData()
const systemInterval = setInterval(() => {
@@ -261,10 +270,11 @@ export function SystemOverview() {
})
}, 10000)
// Clean up interval when component unmounts or becomes inactive
return () => {
clearInterval(systemInterval)
}
}, [])
}, [isActive]) // Add isActive as dependency
useEffect(() => {
const fetchVMs = async () => {
@@ -721,7 +731,11 @@ export function SystemOverview() {
</div>
<div className="pt-3 border-t border-border">
<NetworkTrafficChart timeframe={networkTimeframe} onTotalsCalculated={setNetworkTotals} />
<NetworkTrafficChart
timeframe={networkTimeframe}
onTotalsCalculated={setNetworkTotals}
isActive={isActive}
/>
</div>
</div>
) : (

View File

@@ -350,7 +350,7 @@ export function VirtualMachines() {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action }),
body: JSON.JSON.stringify({ action }),
})
if (response.ok) {
@@ -451,7 +451,7 @@ export function VirtualMachines() {
"/api/system",
fetcher,
{
refreshInterval: 30000,
refreshInterval: 60000, // Changed from 30s to 60s
revalidateOnFocus: false,
},
)
@@ -1275,7 +1275,7 @@ export function VirtualMachines() {
{/* Real IPs (green, without "Real" label) */}
{vmDetails.lxc_ip_info.real_ips.map((ip, index) => (
<Badge
key={`real-${index}`}
key={`ip-real-${selectedVM.vmid}-${ip}-${index}`}
variant="outline"
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) */}
{vmDetails.lxc_ip_info.docker_ips.map((ip, index) => (
<Badge
key={`docker-${index}`}
key={`ip-docker-${selectedVM.vmid}-${ip}-${index}`}
variant="outline"
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">
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
<Badge
key={index}
key={`gpu-${selectedVM.vmid}-${index}-${gpu.substring(0, 20)}`}
variant="outline"
className={
gpu.includes("NVIDIA")
@@ -1426,7 +1426,7 @@ export function VirtualMachines() {
<div className="flex flex-wrap gap-2">
{vmDetails.hardware_info.devices.map((device, index) => (
<Badge
key={index}
key={`device-${selectedVM.vmid}-${index}-${device.substring(0, 20)}`}
variant="outline"
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 },
]