diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 878223cde..15fe0d9af 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -351,6 +351,7 @@ export function GuestRow(props: GuestRowProps) { used={props.guest.memory?.used || 0} total={props.guest.memory?.total || 0} percentOnly={memoryPercentOnly()} + cache={props.guest.memory?.cache || 0} balloon={props.guest.memory?.balloon || 0} swapUsed={props.guest.memory?.swapUsed || 0} swapTotal={props.guest.memory?.swapTotal || 0} diff --git a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx index 32f535b15..1390d0d6c 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx @@ -246,6 +246,14 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) { )}`, }, { label: 'Total', value: formatBytes(props.node.memory?.total || 0) }, + ...(props.node.memory?.cache + ? [ + { + label: 'Reclaimable cache', + value: formatBytes(props.node.memory.cache), + } satisfies NodeOverviewRow, + ] + : []), { label: 'Free', value: formatBytes(props.node.memory?.free || 0) }, ...(props.node.memory?.swapTotal ? [ diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.test.ts new file mode 100644 index 000000000..4be52779c --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel'; + +const GiB = 1024 ** 3; + +describe('buildStackedMemoryBarPresentation', () => { + it('renders the v5 used | reclaimable cache split with the Proxmox reconciliation row', () => { + const presentation = buildStackedMemoryBarPresentation( + { used: 4 * GiB, total: 16 * GiB, cache: 6 * GiB }, + 400, + ); + + expect(presentation.segments.map((segment) => segment.label)).toEqual([ + 'Active', + 'Reclaimable', + ]); + expect(presentation.segments[1].leftPercent).toBeCloseTo(25); + expect(presentation.segments[1].widthPercent).toBeCloseTo(37.5); + + const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value])); + expect(rows['Used']).toBe('4.00 GB'); + expect(rows['Reclaimable cache']).toBe('6.00 GB'); + // Truly free excludes the reclaimable cache: 16 - 4 - 6. + expect(rows['Free']).toBe('6.00 GB'); + // Proxmox counts cache as used: (4 + 6) / 16. + expect(rows['Shown in Proxmox']).toBe('63%'); + }); + + it('keeps the cache segment between active and the balloon limit', () => { + const presentation = buildStackedMemoryBarPresentation( + { used: 4 * GiB, total: 16 * GiB, cache: 2 * GiB, balloon: 8 * GiB }, + 400, + ); + + expect(presentation.segments.map((segment) => segment.label)).toEqual([ + 'Active', + 'Reclaimable', + 'Balloon', + ]); + const balloonSegment = presentation.segments[2]; + expect(balloonSegment.leftPercent).toBeCloseTo(37.5); + expect(balloonSegment.widthPercent).toBeCloseTo(12.5); + + const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value])); + // Ballooning caps the usable ceiling: free = 8 - 4 - 2. + expect(rows['Free']).toBe('2.00 GB'); + }); + + it('matches the pre-cache layout when no cache is reported', () => { + const presentation = buildStackedMemoryBarPresentation( + { used: 4 * GiB, total: 16 * GiB }, + 400, + ); + + expect(presentation.segments.map((segment) => segment.label)).toEqual(['Active']); + const labels = presentation.tooltipRows.map((row) => row.label); + expect(labels).toEqual(['Used', 'Free']); + const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value])); + expect(rows['Free']).toBe('12.0 GB'); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 056ee82f9..e58eb1048 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -298,10 +298,14 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] = const rows: GuestDrawerMemoryRow[] = []; const total = memory.total ?? 0; const used = memory.used ?? 0; + const cache = memory.cache ?? 0; if (total > 0) { rows.push({ label: 'Usage', value: `${formatPercent((used / total) * 100)} ยท ${formatBytes(used)}` }); rows.push({ label: 'Total', value: formatBytes(total) }); + if (cache > 0) { + rows.push({ label: 'Reclaimable cache', value: formatBytes(cache) }); + } if (typeof memory.free === 'number') { rows.push({ label: 'Free', value: formatBytes(memory.free) }); } diff --git a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts index 382092a50..43ac5c2bc 100644 --- a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts @@ -13,6 +13,8 @@ export interface StackedMemoryBarProps { used: number; total: number; percentOnly?: number; + /** Reclaimable buff/cache (available - truly free); used + cache + free โ‰ˆ total. */ + cache?: number; swapUsed?: number; swapTotal?: number; balloon?: number; @@ -52,6 +54,8 @@ export interface StackedMemoryBarPresentation { const MEMORY_COLORS = { active: 'rgba(34, 197, 94, 0.6)', + // Muted amber: reclaimable buff/cache, matching the v5 segment tone. + cache: 'rgba(251, 191, 36, 0.45)', balloon: 'rgba(59, 130, 246, 0.6)', swap: 'rgba(168, 85, 247, 0.6)', }; @@ -87,20 +91,8 @@ function getSegments( const balloon = props.balloon || 0; const hasActiveBallooning = balloon > 0 && balloon < props.total; const usedPercent = (props.used / props.total) * 100; - - if (!hasActiveBallooning) { - if (props.used <= 0) { - return []; - } - return [ - { - color: getMetricColorRgba(usedPercent, 'memory', props.thresholds), - label: 'Active', - leftPercent: 0, - widthPercent: usedPercent, - }, - ]; - } + const cache = props.cache || 0; + const cachePercent = (cache / props.total) * 100; const segments: StackedMemorySegment[] = []; if (props.used > 0) { @@ -112,16 +104,32 @@ function getSegments( }); } - const balloonLimitPercent = Math.max(0, (balloon / props.total) * 100 - usedPercent); - if (balloonLimitPercent > 0 && balloon > props.used) { + // Reclaimable buff/cache rides between active and the balloon limit, like v5. + if (cache > 0) { segments.push({ - color: MEMORY_COLORS.balloon, - label: 'Balloon', + color: MEMORY_COLORS.cache, + label: 'Reclaimable', leftPercent: usedPercent, - widthPercent: balloonLimitPercent, + widthPercent: cachePercent, }); } + if (hasActiveBallooning) { + const usedPlusCache = props.used + cache; + const balloonLimitPercent = Math.max( + 0, + (balloon / props.total) * 100 - usedPercent - cachePercent, + ); + if (balloonLimitPercent > 0 && balloon > usedPlusCache) { + segments.push({ + color: MEMORY_COLORS.balloon, + label: 'Balloon', + leftPercent: usedPercent + cachePercent, + widthPercent: balloonLimitPercent, + }); + } + } + return segments; } @@ -131,6 +139,7 @@ function getTooltipRows( ): StackedMemoryTooltipRow[] { const rows: StackedMemoryTooltipRow[] = []; const balloon = props.balloon || 0; + const cache = props.cache || 0; const hasActiveBallooning = props.total > 0 && balloon > 0 && balloon < props.total; const hasSwap = (props.swapTotal || 0) > 0; @@ -142,6 +151,15 @@ function getTooltipRows( value: formatBytes(props.used), }); + if (cache > 0) { + rows.push({ + borderTop: true, + label: 'Reclaimable cache', + labelClass: 'text-amber-400', + value: formatBytes(cache), + }); + } + if (hasActiveBallooning) { rows.push({ borderTop: true, @@ -151,12 +169,26 @@ function getTooltipRows( }); } + // Truly free pages exclude the reclaimable cache; capped at the balloon + // limit when ballooning is active (the guest cannot use past it). + const ceiling = hasActiveBallooning ? balloon : props.total; rows.push({ borderTop: true, label: 'Free', labelClass: 'text-slate-400', - value: formatBytes(props.total - props.used), + value: formatBytes(Math.max(0, ceiling - props.used - cache)), }); + + // Proxmox's UI counts reclaimable cache as used; this row explains why + // Pulse's percentage reads lower than the same guest in Proxmox. + if (cache > 0) { + rows.push({ + borderTop: true, + label: 'Shown in Proxmox', + labelClass: 'text-slate-500 italic', + value: formatPercent(((props.used + cache) / props.total) * 100), + }); + } } else { rows.push({ borderTop: true, diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index 04af7f3b1..9f1385435 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -474,6 +474,9 @@ export const ProxmoxNodesTable: Component<{ used={memoryUsed()} total={memoryTotal()} percentOnly={memoryPercentOnly()} + cache={drawerNode()?.memory?.cache || 0} + swapUsed={drawerNode()?.memory?.swapUsed || 0} + swapTotal={drawerNode()?.memory?.swapTotal || 0} /> } diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 4a5587c10..7e6c08fe9 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -93,6 +93,7 @@ type APIResource = { swapUsed?: number; swapTotal?: number; balloon?: number; + memoryCache?: number; isOci?: boolean; osTemplate?: string; osName?: string; @@ -455,8 +456,13 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { cpus: resource.proxmox?.cpus ?? 1, memory: (() => { const base = buildMetric(resource.metrics?.memory); + const cache = resource.proxmox?.memoryCache ?? 0; return { ...base, + // buildMetric derives free as total-used (available); carve the + // reclaimable cache back out so free means truly-free pages. + free: Math.max(0, base.free - cache), + cache, swapUsed: resource.proxmox?.swapUsed ?? 0, swapTotal: resource.proxmox?.swapTotal ?? 0, balloon: resource.proxmox?.balloon ?? 0, diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 60de38976..f8749b628 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -1000,6 +1000,8 @@ export interface Memory { total: number; used: number; free: number; + /** Reclaimable buff/cache (available - truly free); used + cache + free โ‰ˆ total. */ + cache?: number; usage: number; balloon?: number; swapUsed?: number; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index fff896a81..f1481cf13 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -617,6 +617,9 @@ export interface ResourceProxmoxMeta { swapUsed?: number; swapTotal?: number; balloon?: number; + /** Reclaimable buff/cache split out of the memory metric's free bytes. */ + memoryCache?: number; + pendingUpdates?: number; isOci?: boolean; osTemplate?: string; pveVersion?: string; diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index f878586fa..7bea089f3 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -774,10 +774,18 @@ export const mergeCanonicalResourceSnapshot = ( ); }; -const buildMemory = (metric: Resource['memory'], fallback?: Record): Memory => { +const buildMemory = ( + metric: Resource['memory'], + fallback?: Record, + proxmoxMeta?: Record, +): Memory => { const total = metric?.total ?? asNumber(fallback?.total) ?? 0; const used = metric?.used ?? asNumber(fallback?.used) ?? 0; - const free = metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used, 0); + const cache = asNumber(proxmoxMeta?.memoryCache) ?? asNumber(fallback?.cache) ?? 0; + // The metric ships no free bytes for PVE payloads; total-used is the + // reclaimable-inclusive available, so carve the cache back out when known. + const free = + metric?.free ?? asNumber(fallback?.free) ?? Math.max(total - used - cache, 0); const usage = metric?.current ?? (total > 0 ? (used / total) * 100 : (asNumber(fallback?.usage) ?? 0)); return { @@ -785,8 +793,9 @@ const buildMemory = (metric: Resource['memory'], fallback?: Record 0 ? cache : undefined, + swapUsed: asNumber(proxmoxMeta?.swapUsed) ?? asNumber(fallback?.swapUsed), + swapTotal: asNumber(proxmoxMeta?.swapTotal) ?? asNumber(fallback?.swapTotal), balloon: asNumber(fallback?.balloon), }; }; @@ -933,7 +942,7 @@ export const nodeFromResource = (resource: Resource): Node | null => { status: resource.status || 'unknown', type: resource.type, cpu: resource.cpu?.current ?? 0, - memory: buildMemory(resource.memory, asRecord(proxmox?.memory)), + memory: buildMemory(resource.memory, asRecord(proxmox?.memory), proxmox), disk: buildDisk(resource.disk, asRecord(proxmox?.disk)), networkIn: resource.network?.rxBytes, networkOut: resource.network?.txBytes, diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 3aeb4115c..56a7d4f78 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -873,7 +873,10 @@ func generateNode(name string, highLoad bool, config MockConfig) models.Node { Memory: models.Memory{ Total: totalMem * 1024 * 1024 * 1024, // Convert to bytes Used: usedMem * 1024 * 1024 * 1024, - Free: (totalMem - usedMem) * 1024 * 1024 * 1024, + // Roughly a third of the non-used pages read as reclaimable + // buff/cache so the node memory split is exercisable in mock mode. + Cache: (totalMem - usedMem) / 3 * 1024 * 1024 * 1024, + Free: ((totalMem - usedMem) - (totalMem-usedMem)/3) * 1024 * 1024 * 1024, Usage: float64(usedMem) / float64(totalMem) * 100, }, Disk: models.Disk{ @@ -1116,6 +1119,12 @@ func generateVM(nodeName string, instance string, vmid int, config MockConfig) m SwapTotal: swapTotal, SwapUsed: swapUsed, } + // Carve reclaimable buff/cache out of the free pages so the + // used | cache | free memory split is exercisable in mock mode. + if mem.Free > 0 { + mem.Cache = int64(float64(mem.Free) * (0.2 + rand.Float64()*0.4)) + mem.Free -= mem.Cache + } uptime = int64(3600 * (1 + rand.Intn(720))) // 1-720 hours } diff --git a/internal/models/models.go b/internal/models/models.go index d68c5a423..86e3b40d5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -71,6 +71,9 @@ type Alert struct { Acknowledged bool `json:"acknowledged"` AckTime *time.Time `json:"ackTime,omitempty"` AckUser string `json:"ackUser,omitempty"` + // Metadata carries alert-engine annotations (notably resourceType) so the + // frontend can classify an alert without re-deriving resource identity. + Metadata map[string]interface{} `json:"metadata,omitempty"` } // ResolvedAlert represents a recently resolved alert @@ -2649,9 +2652,11 @@ type PMGQueueStatus struct { // Memory represents memory usage type Memory struct { - Total int64 `json:"total"` - Used int64 `json:"used"` - Free int64 `json:"free"` + Total int64 `json:"total"` + Used int64 `json:"used"` + Free int64 `json:"free"` + // Reclaimable buff/cache (available - truly free); used + cache + free โ‰ˆ total. + Cache int64 `json:"cache,omitempty"` Usage float64 `json:"usage"` Balloon int64 `json:"balloon,omitempty"` SwapUsed int64 `json:"swapUsed,omitempty"` diff --git a/internal/monitoring/memory_reclaimable.go b/internal/monitoring/memory_reclaimable.go new file mode 100644 index 000000000..6d7c7cf91 --- /dev/null +++ b/internal/monitoring/memory_reclaimable.go @@ -0,0 +1,21 @@ +package monitoring + +import "github.com/rcourtman/pulse-go-rewrite/internal/models" + +// splitReclaimableMemory splits the reclaimable page cache out of a memory +// snapshot whose Free currently holds "available" (Total - Used, with Used +// already excluding cache). trulyFree is the node/guest-reported free page +// count; when it is known and smaller than Free, the gap is reclaimable +// buff/cache and the snapshot becomes used | cache | free, which is what the +// frontend memory bar needs to reconcile Pulse's percentage with the +// cache-inclusive number the Proxmox UI shows. +func splitReclaimableMemory(memory *models.Memory, trulyFree uint64) { + if memory == nil || trulyFree == 0 { + return + } + if memory.Free <= 0 || int64(trulyFree) >= memory.Free { + return + } + memory.Cache = memory.Free - int64(trulyFree) + memory.Free = int64(trulyFree) +} diff --git a/internal/monitoring/memory_reclaimable_test.go b/internal/monitoring/memory_reclaimable_test.go new file mode 100644 index 000000000..35fe3b714 --- /dev/null +++ b/internal/monitoring/memory_reclaimable_test.go @@ -0,0 +1,55 @@ +package monitoring + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestSplitReclaimableMemory(t *testing.T) { + t.Run("splits cache out of available-based free", func(t *testing.T) { + memory := models.Memory{Total: 16, Used: 6, Free: 10, Usage: 37.5} + splitReclaimableMemory(&memory, 4) + if memory.Cache != 6 { + t.Fatalf("Cache = %d, want 6", memory.Cache) + } + if memory.Free != 4 { + t.Fatalf("Free = %d, want 4", memory.Free) + } + if memory.Used != 6 || memory.Total != 16 { + t.Fatalf("Used/Total mutated: %+v", memory) + } + if got := memory.Used + memory.Cache + memory.Free; got != memory.Total { + t.Fatalf("used+cache+free = %d, want %d", got, memory.Total) + } + }) + + t.Run("no-op when truly free is unknown", func(t *testing.T) { + memory := models.Memory{Total: 16, Used: 6, Free: 10} + splitReclaimableMemory(&memory, 0) + if memory.Cache != 0 || memory.Free != 10 { + t.Fatalf("unexpected split: %+v", memory) + } + }) + + t.Run("no-op when truly free is not smaller than free", func(t *testing.T) { + memory := models.Memory{Total: 16, Used: 6, Free: 10} + splitReclaimableMemory(&memory, 10) + if memory.Cache != 0 || memory.Free != 10 { + t.Fatalf("unexpected split: %+v", memory) + } + splitReclaimableMemory(&memory, 12) + if memory.Cache != 0 || memory.Free != 10 { + t.Fatalf("unexpected split with oversized trulyFree: %+v", memory) + } + }) + + t.Run("no-op on nil or non-positive free", func(t *testing.T) { + splitReclaimableMemory(nil, 4) + memory := models.Memory{Total: 16, Used: 16, Free: 0} + splitReclaimableMemory(&memory, 4) + if memory.Cache != 0 { + t.Fatalf("unexpected split on zero free: %+v", memory) + } + }) +} diff --git a/internal/monitoring/monitor_pve_guest_builders.go b/internal/monitoring/monitor_pve_guest_builders.go index d9b956654..cb300b13b 100644 --- a/internal/monitoring/monitor_pve_guest_builders.go +++ b/internal/monitoring/monitor_pve_guest_builders.go @@ -332,6 +332,9 @@ func (m *Monitor) buildVMFromClusterResource( if memory.Used > memory.Total { memory.Used = memory.Total } + // Free above is total-used, i.e. available. When the guest reported its + // truly-free pages (meminfo), split the reclaimable cache back out. + splitReclaimableMemory(&memory, state.guestRaw.MemInfoFree) if state.detailedStatus != nil && state.detailedStatus.Balloon > 0 { memory.Balloon = int64(state.detailedStatus.Balloon) } diff --git a/internal/monitoring/node_memory_sources.go b/internal/monitoring/node_memory_sources.go index 88e4d87f4..ed79e8f08 100644 --- a/internal/monitoring/node_memory_sources.go +++ b/internal/monitoring/node_memory_sources.go @@ -202,6 +202,9 @@ func (m *Monitor) resolveNodeMemory( Free: free, Usage: safePercentage(float64(actualUsed), float64(memory.Total)), } + // Free above is total-used, i.e. available. The node status reports its + // truly-free pages directly, so split the reclaimable cache back out. + splitReclaimableMemory(&resolved, memory.Free) return resolved, source, fallbackReason, raw, true } diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 75aadd020..fbe830bbc 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -63,6 +63,9 @@ func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resourc PendingUpdates: node.PendingUpdates, TemperatureMonitoringEnabled: cloneBoolPtr(node.TemperatureMonitoringEnabled), PendingUpdatesCheckedAt: zeroTimeToPtr(node.PendingUpdatesCheckedAt), + MemoryCache: node.Memory.Cache, + SwapUsed: node.Memory.SwapUsed, + SwapTotal: node.Memory.SwapTotal, LinkedAgentID: linkedAgentID, } @@ -1509,6 +1512,7 @@ func resourceFromVM(vm models.VM) (Resource, ResourceIdentity) { SwapUsed: vm.Memory.SwapUsed, SwapTotal: vm.Memory.SwapTotal, Balloon: vm.Memory.Balloon, + MemoryCache: vm.Memory.Cache, Lock: vm.Lock, } resource := Resource{ @@ -1553,6 +1557,7 @@ func resourceFromContainer(ct models.Container) (Resource, ResourceIdentity) { SwapUsed: ct.Memory.SwapUsed, SwapTotal: ct.Memory.SwapTotal, Balloon: ct.Memory.Balloon, + MemoryCache: ct.Memory.Cache, Lock: ct.Lock, } resource := Resource{ diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index a8197184f..af5584b48 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -354,7 +354,9 @@ type ProxmoxData struct { SwapUsed int64 `json:"swapUsed,omitempty"` SwapTotal int64 `json:"swapTotal,omitempty"` Balloon int64 `json:"balloon,omitempty"` - Lock string `json:"lock,omitempty"` // Proxmox lock state (e.g. "backup", "migrate", "snapshot") + // Reclaimable buff/cache split out of the memory metric's free bytes. + MemoryCache int64 `json:"memoryCache,omitempty"` + Lock string `json:"lock,omitempty"` // Proxmox lock state (e.g. "backup", "migrate", "snapshot") // Internal link hint to a host agent resource. LinkedAgentID string `json:"-"` }