fix: Robust OCI container detection with state persistence

Backend:
- Seed OCI classification from previous state so containers never
  'downgrade' to LXC if config fetching intermittently fails
- Prevent type regression in recordGuestSnapshot when OCI was previously detected
- Move metrics zeroing before snapshot recording for cleaner flow

Frontend:
- Add isOCIContainer() memo that checks both type and isOci flag
- Use isOCI helper in Dashboard.tsx for AI context building
- Include oci-container type in useResources container conversion
- Preserve isOci and osTemplate fields through legacy conversion

This ensures OCI containers retain their classification even when
Proxmox API permissions or transient errors prevent config reads.
This commit is contained in:
rcourtman 2025-12-12 20:06:39 +00:00
parent a20760f527
commit bf43d448cf
14 changed files with 371 additions and 133 deletions

View file

@ -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<string, unknown> = {
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;
}

View file

@ -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) {
<span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: props.guest.type === 'oci'
: isOCIContainer()
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
title={
isVM(props.guest)
? 'Virtual Machine'
: props.guest.type === 'oci'
: isOCIContainer()
? `OCI Container${ociImage() ? `${ociImage()}` : ''}`
: 'LXC Container'
}
>
{isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
</span>
</div>
</td>

View file

@ -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);

View file

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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', () => {

View file

@ -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<string, unknown> | 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 ? {

View file

@ -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, ",")

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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) {

View file

@ -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"`

View file

@ -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