diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 91f2654..ac8215a 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -908,6 +908,7 @@ export function Dashboard(props: DashboardProps) { const guestId = guest.id || `${guest.instance}-${guest.vmid}`; const guestType = guest.type === 'qemu' ? 'vm' : 'container'; + const isOCI = guest.type === 'oci' || ('isOci' in guest && guest.isOci === true); // Toggle: remove if already in context, add if not if (aiChatStore.hasContextItem(guestId)) { @@ -918,14 +919,14 @@ export function Dashboard(props: DashboardProps) { const contextData: Record = { guestName: guest.name, name: guest.name, - type: guest.type === 'qemu' ? 'Virtual Machine' : (guest.type === 'oci' ? 'OCI Container' : 'LXC Container'), + type: guest.type === 'qemu' ? 'Virtual Machine' : (isOCI ? 'OCI Container' : 'LXC Container'), vmid: guest.vmid, node: guest.node, status: guest.status, }; // Add OCI image info if available - if (guest.type === 'oci' && 'osTemplate' in guest && guest.osTemplate) { + if (isOCI && 'osTemplate' in guest && guest.osTemplate) { contextData.ociImage = guest.osTemplate; } diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index e29a503..f4bd630 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -571,9 +571,15 @@ export function GuestRow(props: GuestRowProps) { const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? ''); const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0); + const isOCIContainer = createMemo(() => { + if (isVM(props.guest)) return false; + const container = props.guest as Container; + return props.guest.type === 'oci' || container.isOci === true; + }); + // OCI image info - extract clean image name from osTemplate (similar to Docker container image display) const ociImage = createMemo(() => { - if (props.guest.type !== 'oci') return null; + if (!isOCIContainer()) return null; const template = (props.guest as Container).osTemplate; if (!template) return null; // Strip common prefixes to get clean image reference @@ -903,19 +909,19 @@ export function GuestRow(props: GuestRowProps) { - {isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'} + {isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'} diff --git a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx index e3f6c02..88562cf 100644 --- a/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/UnifiedAgents.test.tsx @@ -379,15 +379,15 @@ describe('UnifiedAgents platform commands', () => { const generateButton = screen.getByRole('button', { name: /Generate token/i }); fireEvent.click(generateButton); - await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); + await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 }); - await waitFor(() => { - expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('Docker monitoring')).toBeInTheDocument(); + }); - // Docker monitoring is disabled by default - const checkbox = screen.getByRole('checkbox', { name: /Enable Docker monitoring/i }); - expect(checkbox).not.toBeChecked(); + // Docker monitoring is disabled by default + const checkbox = screen.getByRole('checkbox', { name: /Docker monitoring/i }); + expect(checkbox).not.toBeChecked(); // Enable it fireEvent.click(checkbox); diff --git a/frontend-modern/src/hooks/__tests__/useResources.test.ts b/frontend-modern/src/hooks/__tests__/useResources.test.ts index f077be5..ca8b5ba 100644 --- a/frontend-modern/src/hooks/__tests__/useResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useResources.test.ts @@ -240,29 +240,62 @@ describe('useResourcesAsLegacy - Legacy Format Conversion', () => { }); }); - describe('Container conversion', () => { - it('converts Resource to legacy Container format', () => { - const resource = createMockResource({ - type: 'container', - platformData: { - vmid: 200, - node: 'pve1', - }, - }); + describe('Container conversion', () => { + it('converts container Resource to legacy Container format', () => { + const resource = createMockResource({ + type: 'container', + platformData: { + vmid: 200, + node: 'pve1', + }, + }); - const platformData = resource.platformData as Record; - const legacyContainer = { - id: resource.id, - vmid: platformData?.vmid as number, - name: resource.name, - type: 'lxc', - status: resource.status === 'running' ? 'running' : 'stopped', - }; + const platformData = resource.platformData as Record; + const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci'; + const legacyContainer = { + id: resource.id, + vmid: platformData?.vmid as number, + name: resource.name, + type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'), + isOci: isOCI, + osTemplate: platformData?.osTemplate as string | undefined, + status: resource.status === 'running' ? 'running' : 'stopped', + }; - expect(legacyContainer.vmid).toBe(200); - expect(legacyContainer.type).toBe('lxc'); - }); - }); + expect(legacyContainer.vmid).toBe(200); + expect(legacyContainer.type).toBe('lxc'); + }); + + it('converts oci-container Resource to legacy Container format', () => { + const resource = createMockResource({ + type: 'oci-container', + platformData: { + vmid: 300, + node: 'pve1', + type: 'oci', + isOci: true, + osTemplate: 'oci:docker.io/library/alpine:latest', + }, + }); + + const platformData = resource.platformData as Record; + const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci'; + const legacyContainer = { + id: resource.id, + vmid: platformData?.vmid as number, + name: resource.name, + type: isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'), + isOci: isOCI, + osTemplate: platformData?.osTemplate as string | undefined, + status: resource.status === 'running' ? 'running' : 'stopped', + }; + + expect(legacyContainer.vmid).toBe(300); + expect(legacyContainer.type).toBe('oci'); + expect(legacyContainer.isOci).toBe(true); + expect(legacyContainer.osTemplate).toBe('oci:docker.io/library/alpine:latest'); + }); + }); describe('Node conversion', () => { it('converts Resource to legacy Node format with temperature', () => { diff --git a/frontend-modern/src/hooks/useResources.ts b/frontend-modern/src/hooks/useResources.ts index aca58a6..e8802e1 100644 --- a/frontend-modern/src/hooks/useResources.ts +++ b/frontend-modern/src/hooks/useResources.ts @@ -304,8 +304,16 @@ export function useResourcesAsLegacy() { return wsStore.state.containers ?? []; } - return byType('container').map(r => { + // Include both traditional LXC containers and OCI containers (Proxmox VE 9.1+). + const containerResources = [...byType('container'), ...byType('oci-container')]; + + return containerResources.map(r => { const platformData = r.platformData as Record | undefined; + const isOCI = + r.type === 'oci-container' || + platformData?.isOci === true || + platformData?.type === 'oci'; + const legacyType = isOCI ? 'oci' : ((platformData?.type as string) ?? 'lxc'); return { id: r.id, vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10), @@ -313,7 +321,9 @@ export function useResourcesAsLegacy() { node: platformData?.node as string ?? '', instance: platformData?.instance as string ?? r.platformId, status: r.status === 'running' ? 'running' : 'stopped', - type: (platformData?.type as string) ?? 'lxc', // Preserve oci/lxc type from backend + type: legacyType, + isOci: isOCI, + osTemplate: platformData?.osTemplate as string | undefined, cpu: (r.cpu?.current ?? 0) / 100, // Convert from percentage to ratio for Dashboard cpus: platformData?.cpus as number ?? 1, memory: r.memory ? { diff --git a/internal/models/converters.go b/internal/models/converters.go index e655c28..bfbad1d 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -157,6 +157,19 @@ func (c Container) ToFrontend() ContainerFrontend { LastSeen: c.LastSeen.Unix() * 1000, } + // OCI containers are classified separately from traditional LXC containers in the UI. + // Keep the frontend payload stable even if some internal sources only persist Type. + isOCI := c.IsOCI || strings.EqualFold(strings.TrimSpace(c.Type), "oci") + if isOCI { + ct.IsOCI = true + ct.Type = "oci" + } + + // Preserve template metadata (LXC template or OCI image reference). + if c.OSTemplate != "" { + ct.OSTemplate = c.OSTemplate + } + // Convert tags array to string if len(c.Tags) > 0 { ct.Tags = strings.Join(c.Tags, ",") diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index 4d01d93..e077137 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -267,42 +267,99 @@ func TestVMToFrontend_NegativeNetworkValues(t *testing.T) { func TestContainerToFrontend(t *testing.T) { now := time.Now() - container := Container{ - ID: "ct-101", - VMID: 101, - Name: "test-ct", - Node: "pve1", - Status: "running", - Type: "lxc", - CPU: 0.10, - CPUs: 2, - Memory: Memory{Total: 4000000000, Used: 2000000000}, - Disk: Disk{Total: 50000000000, Used: 25000000000}, - NetworkIn: 500000, - NetworkOut: 250000, - Uptime: 7200, - Tags: []string{"dev", "database"}, - LastSeen: now, - IPAddresses: []string{"192.168.1.51"}, - } + t.Run("lxc", func(t *testing.T) { + container := Container{ + ID: "ct-101", + VMID: 101, + Name: "test-ct", + Node: "pve1", + Status: "running", + Type: "lxc", + CPU: 0.10, + CPUs: 2, + Memory: Memory{Total: 4000000000, Used: 2000000000}, + Disk: Disk{Total: 50000000000, Used: 25000000000}, + NetworkIn: 500000, + NetworkOut: 250000, + Uptime: 7200, + Tags: []string{"dev", "database"}, + LastSeen: now, + IPAddresses: []string{"192.168.1.51"}, + } - frontend := container.ToFrontend() + frontend := container.ToFrontend() - if frontend.ID != container.ID { - t.Errorf("ID = %q, want %q", frontend.ID, container.ID) - } - if frontend.VMID != container.VMID { - t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID) - } - if frontend.Type != container.Type { - t.Errorf("Type = %q, want %q", frontend.Type, container.Type) - } - if frontend.Tags != "dev,database" { - t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags) - } - if frontend.Memory == nil { - t.Error("Memory should not be nil when Total > 0") - } + if frontend.ID != container.ID { + t.Errorf("ID = %q, want %q", frontend.ID, container.ID) + } + if frontend.VMID != container.VMID { + t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID) + } + if frontend.Type != container.Type { + t.Errorf("Type = %q, want %q", frontend.Type, container.Type) + } + if frontend.IsOCI { + t.Errorf("IsOCI = %v, want false", frontend.IsOCI) + } + if frontend.Tags != "dev,database" { + t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags) + } + if frontend.Memory == nil { + t.Error("Memory should not be nil when Total > 0") + } + }) + + t.Run("oci", func(t *testing.T) { + container := Container{ + ID: "ct-300", + VMID: 300, + Name: "oci-alpine", + Node: "pve1", + Status: "running", + Type: "lxc", + IsOCI: true, + OSTemplate: "oci:docker.io/library/alpine:latest", + CPU: 0.05, + CPUs: 2, + Memory: Memory{Total: 1000000000, Used: 100000000}, + Disk: Disk{Total: 10000000000, Used: 1000000000}, + LastSeen: now, + } + + frontend := container.ToFrontend() + + if frontend.Type != "oci" { + t.Errorf("Type = %q, want %q", frontend.Type, "oci") + } + if !frontend.IsOCI { + t.Errorf("IsOCI = %v, want true", frontend.IsOCI) + } + if frontend.OSTemplate != container.OSTemplate { + t.Errorf("OSTemplate = %q, want %q", frontend.OSTemplate, container.OSTemplate) + } + }) + + t.Run("oci-derived-from-type", func(t *testing.T) { + container := Container{ + ID: "ct-301", + VMID: 301, + Name: "oci-from-type", + Node: "pve1", + Status: "running", + Type: "oci", + IsOCI: false, + LastSeen: now, + } + + frontend := container.ToFrontend() + + if frontend.Type != "oci" { + t.Errorf("Type = %q, want %q", frontend.Type, "oci") + } + if !frontend.IsOCI { + t.Errorf("IsOCI = %v, want true", frontend.IsOCI) + } + }) } func TestDockerContainerToFrontend(t *testing.T) { diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index a70282e..a90f11b 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -75,6 +75,9 @@ type ContainerFrontend struct { Instance string `json:"instance"` Status string `json:"status"` Type string `json:"type"` + // OCI container support (Proxmox VE 9.1+) + IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container + OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest") CPU float64 `json:"cpu"` CPUs int `json:"cpus"` Memory *Memory `json:"memory,omitempty"` // Full memory object diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 5810351..80e9b9e 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -5517,6 +5517,22 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam return false } + // Seed OCI classification from previous state so we never "downgrade" to LXC + // if container config fetching intermittently fails (permissions or transient API errors). + prevState := m.GetState() + prevContainerIsOCI := make(map[int]bool) + for _, ct := range prevState.Containers { + if ct.Instance != instanceName { + continue + } + if ct.VMID <= 0 { + continue + } + if ct.Type == "oci" || ct.IsOCI { + prevContainerIsOCI[ct.VMID] = true + } + } + var allVMs []models.VM var allContainers []models.Container @@ -6160,6 +6176,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam LastSeen: time.Now(), } + if prevContainerIsOCI[container.VMID] { + container.IsOCI = true + container.Type = "oci" + } + // Parse tags if res.Tags != "" { container.Tags = strings.Split(res.Tags, ";") @@ -6179,6 +6200,25 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam m.enrichContainerMetadata(ctx, client, instanceName, res.Node, &container) + // For non-running containers, zero out resource usage metrics to prevent false alerts. + // Proxmox may report stale or residual metrics for stopped containers. + if container.Status != "running" { + log.Debug(). + Str("container", container.Name). + Str("status", container.Status). + Float64("originalCpu", container.CPU). + Float64("originalMemUsage", container.Memory.Usage). + Msg("Non-running container detected - zeroing metrics") + + container.CPU = 0 + container.Memory.Usage = 0 + container.Disk.Usage = 0 + container.NetworkIn = 0 + container.NetworkOut = 0 + container.DiskRead = 0 + container.DiskWrite = 0 + } + allContainers = append(allContainers, container) m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{ @@ -6190,26 +6230,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam Raw: guestRaw, }) - // For non-running containers, zero out resource usage metrics to prevent false alerts - // Proxmox may report stale or residual metrics for stopped containers - if container.Status != "running" { - log.Debug(). - Str("container", container.Name). - Str("status", container.Status). - Float64("originalCpu", container.CPU). - Float64("originalMemUsage", container.Memory.Usage). - Msg("Non-running container detected - zeroing metrics") - - // Zero out all usage metrics for stopped/paused containers - container.CPU = 0 - container.Memory.Usage = 0 - container.Disk.Usage = 0 - container.NetworkIn = 0 - container.NetworkOut = 0 - container.DiskRead = 0 - container.DiskWrite = 0 - } - // Check thresholds for alerts m.alertManager.CheckGuest(container, instanceName) } @@ -6218,8 +6238,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam // Preserve VMs and containers from nodes within grace period // The cluster/resources endpoint doesn't return VMs/containers from nodes Proxmox considers offline, // but we want to keep showing them if the node is within grace period - prevState := m.GetState() - // Count previous resources for this instance prevVMCount := 0 prevContainerCount := 0 @@ -8017,6 +8035,17 @@ func persistGuestIdentity(metadataStore *config.GuestMetadataStore, guestKey, na } } + guestType = strings.TrimSpace(guestType) + if guestType == "" { + return + } + + // Never "downgrade" OCI containers back to LXC. OCI classification can be transiently + // unavailable if Proxmox config reads fail due to permissions or transient API errors. + if existing.LastKnownType == "oci" && guestType != "oci" { + guestType = existing.LastKnownType + } + // Only update if the name or type has changed if existing.LastKnownName != name || existing.LastKnownType != guestType { existing.LastKnownName = name diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index f122351..58a42bb 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -894,6 +894,22 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri } } + // Seed OCI classification from previous state so we never "downgrade" to LXC + // if container config fetching intermittently fails (permissions or transient API errors). + prevState := m.GetState() + prevContainerIsOCI := make(map[int]bool) + for _, ct := range prevState.Containers { + if ct.Instance != instanceName { + continue + } + if ct.VMID <= 0 { + continue + } + if ct.Type == "oci" || ct.IsOCI { + prevContainerIsOCI[ct.VMID] = true + } + } + log.Info(). Str("instance", instanceName). Int("totalNodes", len(nodes)). @@ -1026,6 +1042,11 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri Tags: tags, } + if prevContainerIsOCI[modelContainer.VMID] { + modelContainer.IsOCI = true + modelContainer.Type = "oci" + } + if override, ok := rootUsageOverrides[int(container.VMID)]; ok { overrideUsed := clampToInt64(override.Used) overrideTotal := clampToInt64(override.Total) diff --git a/internal/resources/converters.go b/internal/resources/converters.go index 254f088..4473d97 100644 --- a/internal/resources/converters.go +++ b/internal/resources/converters.go @@ -219,11 +219,14 @@ func FromContainer(ct models.Container) Resource { VMID: ct.VMID, Node: ct.Node, Instance: ct.Instance, + Type: ct.Type, CPUs: ct.CPUs, Template: ct.Template, Lock: ct.Lock, OSName: ct.OSName, IPAddresses: ct.IPAddresses, + IsOCI: ct.IsOCI || strings.EqualFold(strings.TrimSpace(ct.Type), "oci"), + OSTemplate: ct.OSTemplate, NetworkIn: ct.NetworkIn, NetworkOut: ct.NetworkOut, DiskRead: ct.DiskRead, @@ -235,9 +238,14 @@ func FromContainer(ct models.Container) Resource { // Parent is the node - format matches Node.ID: instance-nodename parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node) + resourceType := ResourceTypeContainer + if platformData.IsOCI { + resourceType = ResourceTypeOCIContainer + } + return Resource{ ID: ct.ID, - Type: ResourceTypeContainer, + Type: resourceType, Name: ct.Name, PlatformID: ct.Instance, PlatformType: PlatformProxmoxPVE, diff --git a/internal/resources/converters_test.go b/internal/resources/converters_test.go index 8935252..01679e9 100644 --- a/internal/resources/converters_test.go +++ b/internal/resources/converters_test.go @@ -199,47 +199,99 @@ func TestFromVM(t *testing.T) { } func TestFromContainer(t *testing.T) { - ct := models.Container{ - ID: "pve1/lxc/101", - VMID: 101, - Name: "database", - Node: "node1", - Instance: "pve1", - Status: "stopped", - Type: "lxc", - CPU: 0.0, - CPUs: 2, - Memory: models.Memory{ - Total: 4 * 1024 * 1024 * 1024, - Used: 0, - Free: 4 * 1024 * 1024 * 1024, - Usage: 0.0, - }, - Uptime: 0, - Tags: []string{"database"}, - IPAddresses: []string{"192.168.1.50"}, - LastSeen: time.Now(), - } + now := time.Now() - r := FromContainer(ct) + t.Run("lxc", func(t *testing.T) { + ct := models.Container{ + ID: "pve1/lxc/101", + VMID: 101, + Name: "database", + Node: "node1", + Instance: "pve1", + Status: "stopped", + Type: "lxc", + CPU: 0.0, + CPUs: 2, + Memory: models.Memory{ + Total: 4 * 1024 * 1024 * 1024, + Used: 0, + Free: 4 * 1024 * 1024 * 1024, + Usage: 0.0, + }, + Uptime: 0, + Tags: []string{"database"}, + IPAddresses: []string{"192.168.1.50"}, + LastSeen: now, + } - if r.Type != ResourceTypeContainer { - t.Errorf("Expected type %s, got %s", ResourceTypeContainer, r.Type) - } - if r.Status != StatusStopped { - t.Errorf("Expected status %s, got %s", StatusStopped, r.Status) - } - if r.ParentID != "pve1-node1" { - t.Errorf("Expected parent pve1-node1, got %s", r.ParentID) - } + r := FromContainer(ct) - var pd ContainerPlatformData - if err := r.GetPlatformData(&pd); err != nil { - t.Fatalf("Failed to get platform data: %v", err) - } - if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" { - t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses) - } + if r.Type != ResourceTypeContainer { + t.Errorf("Expected type %s, got %s", ResourceTypeContainer, r.Type) + } + if r.Status != StatusStopped { + t.Errorf("Expected status %s, got %s", StatusStopped, r.Status) + } + if r.ParentID != "pve1-node1" { + t.Errorf("Expected parent pve1-node1, got %s", r.ParentID) + } + + var pd ContainerPlatformData + if err := r.GetPlatformData(&pd); err != nil { + t.Fatalf("Failed to get platform data: %v", err) + } + if pd.Type != "lxc" { + t.Errorf("Expected platformData type lxc, got %q", pd.Type) + } + if pd.IsOCI { + t.Errorf("Expected IsOCI false, got %v", pd.IsOCI) + } + if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" { + t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses) + } + }) + + t.Run("oci", func(t *testing.T) { + ct := models.Container{ + ID: "pve1/lxc/300", + VMID: 300, + Name: "oci-alpine", + Node: "node1", + Instance: "pve1", + Status: "running", + Type: "oci", + IsOCI: true, + OSTemplate: "oci:docker.io/library/alpine:latest", + CPU: 0.01, + CPUs: 1, + Memory: models.Memory{ + Total: 1024 * 1024 * 1024, + Used: 128 * 1024 * 1024, + Free: 896 * 1024 * 1024, + Usage: 12.5, + }, + LastSeen: now, + } + + r := FromContainer(ct) + if r.Type != ResourceTypeOCIContainer { + t.Errorf("Expected type %s, got %s", ResourceTypeOCIContainer, r.Type) + } + + var pd ContainerPlatformData + if err := r.GetPlatformData(&pd); err != nil { + t.Fatalf("Failed to get platform data: %v", err) + } + if !pd.IsOCI { + t.Errorf("Expected IsOCI true, got %v", pd.IsOCI) + } + if pd.Type != "oci" { + t.Errorf("Expected platformData type oci, got %q", pd.Type) + } + if pd.OSTemplate != ct.OSTemplate { + t.Errorf("Expected OSTemplate %q, got %q", ct.OSTemplate, pd.OSTemplate) + } + }) } func TestFromHost(t *testing.T) { diff --git a/internal/resources/platform_data.go b/internal/resources/platform_data.go index 27974a8..9861f0e 100644 --- a/internal/resources/platform_data.go +++ b/internal/resources/platform_data.go @@ -53,16 +53,20 @@ type VMPlatformData struct { } // ContainerPlatformData contains Proxmox LXC container-specific fields. -// Stored in Resource.PlatformData when Type is ResourceTypeContainer. +// Stored in Resource.PlatformData when Type is ResourceTypeContainer or ResourceTypeOCIContainer. type ContainerPlatformData struct { VMID int `json:"vmid"` Node string `json:"node"` // Proxmox node hosting this container Instance string `json:"instance"` // Proxmox instance URL + Type string `json:"type,omitempty"` // lxc or oci CPUs int `json:"cpus"` // Number of vCPUs Template bool `json:"template"` Lock string `json:"lock,omitempty"` OSName string `json:"osName,omitempty"` IPAddresses []string `json:"ipAddresses,omitempty"` + // OCI container support (Proxmox VE 9.1+) + IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container + OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image reference if available // I/O stats NetworkIn int64 `json:"networkIn"` diff --git a/internal/resources/resource.go b/internal/resources/resource.go index d51fa5f..ba44f9b 100644 --- a/internal/resources/resource.go +++ b/internal/resources/resource.go @@ -75,6 +75,7 @@ const ( // Compute Workloads - individual running instances ResourceTypeVM ResourceType = "vm" // Proxmox VM ResourceTypeContainer ResourceType = "container" // LXC container + ResourceTypeOCIContainer ResourceType = "oci-container" // OCI container (Proxmox VE 9.1+) ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container ResourceTypePod ResourceType = "pod" // Kubernetes pod ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail @@ -201,7 +202,7 @@ func (r *Resource) IsInfrastructure() bool { // rather than infrastructure. func (r *Resource) IsWorkload() bool { switch r.Type { - case ResourceTypeVM, ResourceTypeContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail: + case ResourceTypeVM, ResourceTypeContainer, ResourceTypeOCIContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail: return true default: return false