From ebcafa1dac56b6f25d35190182ab71808af2c4ec Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 25 Nov 2025 17:19:36 +0000 Subject: [PATCH] feat: adaptive node table layout, guest row fixes, and legacy agent detection - Implemented adaptive layout for NodeSummaryTable with responsive columns and sticky name column. - Fixed GuestRow background display issues. - Added IsLegacy field to Host and DockerHost models to flag legacy agents (version < 1.0.0). - Updated monitor to populate IsLegacy based on agent version. --- .../src/components/Dashboard/Dashboard.tsx | 307 ++++---- .../src/components/Dashboard/GuestDrawer.tsx | 200 +++++ .../src/components/Dashboard/GuestRow.tsx | 433 ++++------- .../src/components/Dashboard/IOMetric.tsx | 4 +- .../src/components/Dashboard/MetricBar.tsx | 19 +- .../Docker/DockerHostSummaryTable.tsx | 94 ++- .../src/components/Settings/UnifiedAgents.tsx | 29 +- .../src/components/Storage/Storage.tsx | 2 +- .../src/components/shared/NodeGroupHeader.tsx | 97 ++- .../components/shared/NodeSummaryTable.tsx | 702 ++++++++++-------- frontend-modern/src/types/api.ts | 100 +-- frontend-modern/src/utils/format.ts | 6 +- internal/models/models.go | 78 +- internal/monitoring/monitor.go | 19 + 14 files changed, 1140 insertions(+), 950 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/GuestDrawer.tsx diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index e9bb07e..80832d9 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -6,7 +6,6 @@ import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; -import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery'; import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector'; import { DashboardFilter } from './DashboardFilter'; @@ -926,163 +925,157 @@ export function Dashboard(props: DashboardProps) { {/* Table View */} 0}> - - - - - - - - - - - - - - - - - - - - { - // Sort by friendly node name first, falling back to instance ID for stability - const nodeA = nodeByInstance()[instanceIdA]; - const nodeB = nodeByInstance()[instanceIdB]; - const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; - const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB; - const nameCompare = labelA.localeCompare(labelB); - if (nameCompare !== 0) return nameCompare; - // If labels match (unlikely), fall back to the instance IDs - return instanceIdA.localeCompare(instanceIdB); - })} - fallback={<>} - > - {([instanceId, guests]) => { - const node = nodeByInstance()[instanceId]; + + {/* Desktop Header - Hidden on mobile/tablet, visible on xl+ */} +
+ {/* Name Header - Sticky on mobile */} +
handleSort('name')} + > + Name + {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} +
- return ( - <> - - - - }> - {(guest) => { - // Match backend ID generation logic: stable format is "instance-vmid" - const guestId = - guest.id || `${guest.instance}-${guest.vmid}`; - const metadata = - guestMetadata()[guestId] || - guestMetadata()[`${guest.node}-${guest.vmid}`]; - const parentNode = node ?? resolveParentNode(guest); - const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; - return ( - - - - ); - }} - - - ); - }} - -
-
handleSort('name')} - onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} - tabindex="0" - role="button" - aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - > - Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('cpu')} - > - CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} - handleSort('memory')} - title="Memory" - > - - Mem{' '} - {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} -
-
+ {/* Metrics Headers - Scrollable on mobile */} +
+
+
handleSort('type')} + > + + + + + {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+
handleSort('vmid')} + > + + + + + {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+
handleSort('uptime')} + > + + + + + {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+
handleSort('cpu')} + > + + + + + {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+
handleSort('memory')} + > + + + + + {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+
handleSort('disk')} + > + + + + + {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')} +
+ + + + +
+
+ + + {/* Guest List */} +
+ { + const nodeA = nodeByInstance()[instanceIdA]; + const nodeB = nodeByInstance()[instanceIdB]; + const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; + const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB; + return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB); + })} + fallback={<>} + > + {([instanceId, guests]) => { + const node = nodeByInstance()[instanceId]; + return ( + <> + + + + }> + {(guest) => { + const guestId = guest.id || `${guest.instance}-${guest.vmid}`; + const metadata = + guestMetadata()[guestId] || + guestMetadata()[`${guest.node}-${guest.vmid}`]; + const parentNode = node ?? resolveParentNode(guest); + const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; + return ( + + + + ); + }} + + + ); + }} + +
diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx new file mode 100644 index 0000000..775b811 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx @@ -0,0 +1,200 @@ +import { Component, Show, For } from 'solid-js'; +import { VM, Container } from '@/types/api'; +import { formatBytes } from '@/utils/format'; +import { DiskList } from './DiskList'; +import { IOMetric } from './IOMetric'; + +type Guest = VM | Container; + +interface GuestDrawerProps { + guest: Guest; + metricsKey: string; + onClose: () => void; +} + +export const GuestDrawer: Component = (props) => { + const isVM = (guest: Guest): guest is VM => { + return guest.type === 'qemu'; + }; + + const hasOsInfo = () => { + if (!isVM(props.guest)) return false; + return (props.guest.osInfo?.name?.length ?? 0) > 0 || (props.guest.osInfo?.version?.length ?? 0) > 0; + }; + + const osName = () => { + if (!isVM(props.guest)) return ''; + return props.guest.osInfo?.name || ''; + }; + + const osVersion = () => { + if (!isVM(props.guest)) return ''; + return props.guest.osInfo?.version || ''; + }; + + const hasAgentInfo = () => { + if (!isVM(props.guest)) return false; + return !!props.guest.agentVersion; + }; + + const agentVersion = () => { + if (!isVM(props.guest)) return ''; + return props.guest.agentVersion || ''; + }; + + const ipAddresses = () => { + if (!isVM(props.guest)) return []; + return props.guest.ipAddresses || []; + }; + + const memoryExtraLines = () => { + if (!props.guest.memory) return undefined; + const lines: string[] = []; + const total = props.guest.memory.total ?? 0; + if ( + props.guest.memory.balloon && + props.guest.memory.balloon > 0 && + props.guest.memory.balloon !== total + ) { + lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon, 0)}`); + } + if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) { + const swapUsed = props.guest.memory.swapUsed ?? 0; + lines.push(`Swap: ${formatBytes(swapUsed, 0)} / ${formatBytes(props.guest.memory.swapTotal, 0)}`); + } + return lines.length > 0 ? lines : undefined; + }; + + const hasFilesystemDetails = () => { + if (!props.guest.disks) return false; + return props.guest.disks.length > 0; + }; + + const networkInterfaces = () => { + if (!isVM(props.guest)) return []; + return props.guest.networkInterfaces || []; + }; + + const hasNetworkInterfaces = () => { + return networkInterfaces().length > 0; + }; + + return ( +
+ {/* Left Column: Guest Overview */} +
+
Guest Overview
+
+ +
+ 0}> + {osName()} + + 0 && osVersion().length > 0}> + + + 0}> + {osVersion()} + +
+
+ +
+ + Agent + + + QEMU guest agent {agentVersion()} + +
+
+ 0}> +
+ + {(ip) => ( + + {ip} + + )} + +
+
+
+
+ + {/* Middle Column: Memory Details */} + 0}> +
+
Memory
+
+ {(line) =>
{line}
}
+
+
+
+ + {/* Right Column: Filesystems */} + 0}> +
+
Filesystems
+
+ +
+
+
+ + {/* Far Right Column: Network Interfaces */} + +
+
Network Interfaces
+
Row charts show current rate; totals below are cumulative since boot.
+
+ + {(iface) => { + const addresses = iface.addresses ?? []; + const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0; + return ( +
+
+ {iface.name || 'interface'} + + + {iface.mac} + + +
+ 0}> +
+ + {(ip) => ( + + {ip} + + )} + +
+
+ +
+ Total RX {formatBytes(iface.rxBytes ?? 0)} + Total TX {formatBytes(iface.txBytes ?? 0)} +
+
+
+ ); + }} +
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index c80890b..e97562f 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,10 +1,11 @@ -import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; +import { GuestDrawer } from './GuestDrawer'; +import { createMemo, createSignal, createEffect, Show } from 'solid-js'; import type { VM, Container } from '@/types/api'; import { formatBytes, formatPercent, formatUptime } from '@/utils/format'; import { MetricBar } from './MetricBar'; import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; -import { DiskList } from './DiskList'; + import { StatusDot } from '@/components/shared/StatusDot'; import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; import { GuestMetadataAPI } from '@/api/guestMetadata'; @@ -381,7 +382,7 @@ export function GuestRow(props: GuestRowProps) { return '#9ca3af'; }); - const drawerDisabled = createMemo(() => !isRunning()); + // Get row styling - include alert styles if present const rowClass = createMemo(() => { @@ -391,7 +392,7 @@ export function GuestRow(props: GuestRowProps) { ? props.alertStyles?.severity === 'critical' ? 'bg-red-50 dark:bg-red-950/30' : 'bg-yellow-50 dark:bg-yellow-950/20' - : ''; + : 'bg-white dark:bg-gray-800'; const defaultHover = hasUnacknowledgedAlert() ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700/30'; @@ -403,13 +404,7 @@ export function GuestRow(props: GuestRowProps) { return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`; }); - // Get first cell styling - const firstCellClass = createMemo(() => { - const base = - 'py-0.5 pr-2 whitespace-nowrap relative w-auto'; - const indent = props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT; - return `${base} ${indent}`; - }); + // Get row styles including box-shadow for alert border const rowStyle = createMemo(() => { @@ -423,10 +418,15 @@ export function GuestRow(props: GuestRowProps) { return ( <> - - {/* Name - Sticky column */} - -
+
+ {/* Name Section - Sticky on mobile */} +
+
- {/* Name - show input when editing, otherwise show name with optional link */}
- {/* Tag badges - hide when editing URL to save space */} -
event.stopPropagation()}> + - +
- {/* Type */} - -
- - {isVM(props.guest) ? 'VM' : 'LXC'} - -
- - - {/* VMID */} - - {props.guest.vmid} - - - {/* Uptime */} - - - {formatUptime(props.guest.uptime)} - - - - {/* CPU */} - - -}> - - - - - {/* Memory */} - -
- -}> - - -
- - - {/* Disk – surface usage even if guest is currently stopped so users can see last reported values */} - - - - - - } - > - - - - - {/* Disk I/O */} - -
- -
- - -
- -
- - - {/* Network I/O */} - -
- -
- - -
- -
- - - - - -
- -
- Guest details unavailable -
-
- - Start this container and ensure the Pulse user has sufficient Proxmox - permissions to collect guest metrics. -

- } - > -

{getDiskStatusTooltip()}

-

- Install and run the qemu-guest-agent inside this VM so Pulse can surface - OS, network, and filesystem details. -

-
-
-
- } + {/* Metrics Section - Scrollable on mobile */} +
+
+ {/* Type */} +
+ - <> - 0}> -
-
Guest Overview
-
- -
- 0}> - {osName()} - - 0 && osVersion().length > 0}> - - - 0}> - {osVersion()} - -
-
- -
- - Agent - - - QEMU guest agent {agentVersion()} - -
-
- 0}> -
- - {(ip) => ( - - {ip} - - )} - -
-
-
-
-
+ {isVM(props.guest) ? 'VM' : 'LXC'} +
+
- 0}> -
-
Memory
-
- {(line) =>
{line}
}
-
-
-
+ {/* VMID */} +
+ {props.guest.vmid} +
- 0}> -
-
Filesystems
-
- -
-
-
+ {/* Uptime */} +
+
+ + {formatUptime(props.guest.uptime, true)} + + +
+
- -
-
Network Interfaces
-
Row charts show current rate; totals below are cumulative since boot.
-
- - {(iface) => { - const addresses = iface.addresses ?? []; - const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0; - return ( -
-
- {iface.name || 'interface'} - - - {iface.mac} - - -
- 0}> -
- - {(ip) => ( - - {ip} - - )} - -
-
- -
- Total RX {formatBytes(iface.rxBytes ?? 0)} - Total TX {formatBytes(iface.txBytes ?? 0)} -
-
-
- ); - }} -
-
-
-
- + {/* CPU */} +
+ -}> +
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> + {formatPercent(cpuPercent())} +
+
- - + + {/* Memory */} +
+
+ -}> +
= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memPercent() >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> + {formatPercent(memPercent())} +
+ +
+
+
+ + {/* Disk */} +
+ + - + + } + > +
= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}> + {formatPercent(diskPercent())} +
+ +
+
+ + {/* Disk I/O */} + + + + {/* Network I/O */} + + +
+
+
+ + +
+
+ setCurrentlyExpandedGuestId(null)} + /> +
+
); -} +}; diff --git a/frontend-modern/src/components/Dashboard/IOMetric.tsx b/frontend-modern/src/components/Dashboard/IOMetric.tsx index 68873e1..f48475b 100644 --- a/frontend-modern/src/components/Dashboard/IOMetric.tsx +++ b/frontend-modern/src/components/Dashboard/IOMetric.tsx @@ -38,9 +38,9 @@ export function IOMetric(props: IOMetricProps) { return ( -
} + fallback={
-
} > -
+
{formatSpeed(currentValue(), 0)}
diff --git a/frontend-modern/src/components/Dashboard/MetricBar.tsx b/frontend-modern/src/components/Dashboard/MetricBar.tsx index 0360d56..d3c6661 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.tsx @@ -67,28 +67,15 @@ export function MetricBar(props: MetricBarProps) { when={viewMode() === 'sparklines' && props.resourceId} fallback={ // Original progress bar mode -
- {/* On very small screens (< lg), show compact percentage with color indicator */} -
- {/* Slim color indicator bar */} -
- - {props.label} - -
- {/* On larger screens (>= lg), show full progress bar */} - diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index b8c9e61..aaa464b 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -166,6 +166,7 @@ export interface DockerHost { hidden?: boolean; pendingUninstall?: boolean; command?: DockerHostCommand; + isLegacy?: boolean; } export interface DockerHostCommand { @@ -343,6 +344,7 @@ export interface Host { revokedTokenId?: string; tokenRevokedAt?: number; tags?: string[]; + isLegacy?: boolean; } export interface HostNetworkInterface { @@ -914,60 +916,60 @@ export type WSMessage = | { type: 'alertResolved'; data: { alertId: string } } | { type: 'settingsUpdate'; data: { theme?: string } } | { - type: 'update:progress'; - data: { - phase: string; - progress: number; - message: string; - }; - } + type: 'update:progress'; + data: { + phase: string; + progress: number; + message: string; + }; + } | { - type: 'node_auto_registered'; - data: { - type: string; - host: string; - name: string; - tokenId: string; - hasToken: boolean; - verifySSL?: boolean; - status?: string; - }; - } + type: 'node_auto_registered'; + data: { + type: string; + host: string; + name: string; + tokenId: string; + hasToken: boolean; + verifySSL?: boolean; + status?: string; + }; + } | { type: 'node_deleted'; data: { nodeType: string } } | { type: 'nodes_changed'; data?: unknown } | { - type: 'discovery_update'; - data: { - servers: Array<{ - ip: string; - port: number; - type: string; - version: string; - hostname?: string; - release?: string; - }>; - errors?: string[]; - timestamp?: number; - immediate?: boolean; - scanning?: boolean; - cached?: boolean; - }; - } - | { - type: 'discovery_started'; - data?: { - subnet?: string; - timestamp?: number; - scanning?: boolean; - }; - } - | { - type: 'discovery_complete'; - data?: { - timestamp?: number; - scanning?: boolean; - }; + type: 'discovery_update'; + data: { + servers: Array<{ + ip: string; + port: number; + type: string; + version: string; + hostname?: string; + release?: string; + }>; + errors?: string[]; + timestamp?: number; + immediate?: boolean; + scanning?: boolean; + cached?: boolean; }; + } + | { + type: 'discovery_started'; + data?: { + subnet?: string; + timestamp?: number; + scanning?: boolean; + }; + } + | { + type: 'discovery_complete'; + data?: { + timestamp?: number; + scanning?: boolean; + }; + }; // Utility types export type Status = 'running' | 'stopped' | 'paused' | 'unknown'; diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index d2feb52..cf0aab1 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -25,7 +25,7 @@ export function formatPercent(value: number): string { return `${Math.round(value)}%`; } -export function formatUptime(seconds: number): string { +export function formatUptime(seconds: number, condensed = false): string { if (!seconds || seconds < 0) return '0s'; const days = Math.floor(seconds / 86400); @@ -33,9 +33,9 @@ export function formatUptime(seconds: number): string { const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { - return `${days}d ${hours}h`; + return condensed ? `${days}d` : `${days}d ${hours}h`; } else if (hours > 0) { - return `${hours}h ${minutes}m`; + return condensed ? `${hours}h` : `${hours}h ${minutes}m`; } else { return `${minutes}m`; } diff --git a/internal/models/models.go b/internal/models/models.go index b83c3ac..53a092a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -9,26 +9,26 @@ import ( // State represents the current state of all monitored resources type State struct { - mu sync.RWMutex - Nodes []Node `json:"nodes"` - VMs []VM `json:"vms"` - Containers []Container `json:"containers"` - DockerHosts []DockerHost `json:"dockerHosts"` - RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` - Hosts []Host `json:"hosts"` - Storage []Storage `json:"storage"` - CephClusters []CephCluster `json:"cephClusters"` - PhysicalDisks []PhysicalDisk `json:"physicalDisks"` - PBSInstances []PBSInstance `json:"pbs"` - PMGInstances []PMGInstance `json:"pmg"` - PBSBackups []PBSBackup `json:"pbsBackups"` - PMGBackups []PMGBackup `json:"pmgBackups"` - Backups Backups `json:"backups"` - ReplicationJobs []ReplicationJob `json:"replicationJobs"` - Metrics []Metric `json:"metrics"` - PVEBackups PVEBackups `json:"pveBackups"` - Performance Performance `json:"performance"` - ConnectionHealth map[string]bool `json:"connectionHealth"` + mu sync.RWMutex + Nodes []Node `json:"nodes"` + VMs []VM `json:"vms"` + Containers []Container `json:"containers"` + DockerHosts []DockerHost `json:"dockerHosts"` + RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"` + Hosts []Host `json:"hosts"` + Storage []Storage `json:"storage"` + CephClusters []CephCluster `json:"cephClusters"` + PhysicalDisks []PhysicalDisk `json:"physicalDisks"` + PBSInstances []PBSInstance `json:"pbs"` + PMGInstances []PMGInstance `json:"pmg"` + PBSBackups []PBSBackup `json:"pbsBackups"` + PMGBackups []PMGBackup `json:"pmgBackups"` + Backups Backups `json:"backups"` + ReplicationJobs []ReplicationJob `json:"replicationJobs"` + Metrics []Metric `json:"metrics"` + PVEBackups PVEBackups `json:"pveBackups"` + Performance Performance `json:"performance"` + ConnectionHealth map[string]bool `json:"connectionHealth"` Stats Stats `json:"stats"` ActiveAlerts []Alert `json:"activeAlerts"` RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` @@ -62,28 +62,28 @@ type ResolvedAlert struct { // Node represents a Proxmox VE node type Node struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"displayName,omitempty"` - Instance string `json:"instance"` - Host string `json:"host"` // Full host URL from config - GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation) - Status string `json:"status"` - Type string `json:"type"` - CPU float64 `json:"cpu"` - Memory Memory `json:"memory"` - Disk Disk `json:"disk"` - Uptime int64 `json:"uptime"` - LoadAverage []float64 `json:"loadAverage"` - KernelVersion string `json:"kernelVersion"` - PVEVersion string `json:"pveVersion"` - CPUInfo CPUInfo `json:"cpuInfo"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Instance string `json:"instance"` + Host string `json:"host"` // Full host URL from config + GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation) + Status string `json:"status"` + Type string `json:"type"` + CPU float64 `json:"cpu"` + Memory Memory `json:"memory"` + Disk Disk `json:"disk"` + Uptime int64 `json:"uptime"` + LoadAverage []float64 `json:"loadAverage"` + KernelVersion string `json:"kernelVersion"` + PVEVersion string `json:"pveVersion"` + CPUInfo CPUInfo `json:"cpuInfo"` Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override LastSeen time.Time `json:"lastSeen"` ConnectionHealth string `json:"connectionHealth"` - IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster - ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) + IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster + ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) } // VM represents a virtual machine @@ -174,6 +174,7 @@ type Host struct { TokenHint string `json:"tokenHint,omitempty"` TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` Tags []string `json:"tags,omitempty"` + IsLegacy bool `json:"isLegacy,omitempty"` } // HostNetworkInterface describes a host network adapter summary. @@ -254,6 +255,7 @@ type DockerHost struct { Hidden bool `json:"hidden"` PendingUninstall bool `json:"pendingUninstall"` Command *DockerHostCommandStatus `json:"command,omitempty"` + IsLegacy bool `json:"isLegacy,omitempty"` } // RemovedDockerHost tracks a docker host that was deliberately removed and blocked from reporting. diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 5820ed6..6cd5063 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1877,6 +1877,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con Services: services, Tasks: tasks, Swarm: swarmInfo, + IsLegacy: isLegacyDockerAgent(report.Agent.Version), } if tokenRecord != nil { @@ -2159,6 +2160,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. LastSeen: timestamp, AgentVersion: strings.TrimSpace(report.Agent.Version), Tags: append([]string(nil), report.Tags...), + IsLegacy: isLegacyHostAgent(report.Agent.Version), } if len(host.LoadAverage) == 0 { @@ -9560,3 +9562,20 @@ func (m *Monitor) checkMockAlerts() { // mock state without needing to grab the alert manager lock again. mock.UpdateAlertSnapshots(m.alertManager.GetActiveAlerts(), m.alertManager.GetRecentlyResolved()) } +func isLegacyHostAgent(version string) bool { + // New unified agent is 1.0.0+ + // Legacy agents are 0.x.x or empty + if version == "" { + return true + } + return strings.HasPrefix(version, "0.") +} + +func isLegacyDockerAgent(version string) bool { + // New unified agent is 1.0.0+ + // Legacy agents are 0.x.x or empty + if version == "" { + return true + } + return strings.HasPrefix(version, "0.") +}