diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx
index 6d9e07e..e675836 100644
--- a/frontend-modern/src/components/Dashboard/Dashboard.tsx
+++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx
@@ -549,7 +549,8 @@ export function Dashboard(props: DashboardProps) {
if (viewMode() === 'vm') {
guests = guests.filter((g) => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
- guests = guests.filter((g) => g.type === 'lxc');
+ // Include both traditional LXC and OCI containers (Proxmox 9.1+)
+ guests = guests.filter((g) => g.type === 'lxc' || g.type === 'oci');
}
// Filter by status
@@ -807,7 +808,7 @@ export function Dashboard(props: DashboardProps) {
}).length;
const stopped = guests.length - running - degraded;
const vms = guests.filter((g) => g.type === 'qemu').length;
- const containers = guests.filter((g) => g.type === 'lxc').length;
+ const containers = guests.filter((g) => g.type === 'lxc' || g.type === 'oci').length;
return {
total: guests.length,
running,
@@ -916,7 +917,7 @@ export function Dashboard(props: DashboardProps) {
aiChatStore.addContextItem(guestType, guestId, guest.name, {
guestName: guest.name,
name: guest.name,
- type: guest.type === 'qemu' ? 'Virtual Machine' : 'LXC Container',
+ type: guest.type === 'qemu' ? 'Virtual Machine' : (guest.type === 'oci' ? 'OCI Container' : 'LXC Container'),
vmid: guest.vmid,
node: guest.node,
status: guest.status,
@@ -939,7 +940,7 @@ export function Dashboard(props: DashboardProps) {
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
- filteredContainers={filteredGuests().filter((g) => g.type === 'lxc')}
+ filteredContainers={filteredGuests().filter((g) => g.type === 'lxc' || g.type === 'oci')}
searchTerm={search()}
/>
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx
index 73fc590..3fc5a9b 100644
--- a/frontend-modern/src/components/Dashboard/GuestRow.tsx
+++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx
@@ -890,12 +890,20 @@ export function GuestRow(props: GuestRowProps) {
- {isVM(props.guest) ? 'VM' : 'LXC'}
+ {isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts
index bb8685b..f8d81d4 100644
--- a/frontend-modern/src/types/api.ts
+++ b/frontend-modern/src/types/api.ts
@@ -130,6 +130,9 @@ export interface Container {
tags: string[] | string | null;
lock: string;
lastSeen: string;
+ // OCI container support (Proxmox VE 9.1+)
+ isOci?: boolean; // True if this is an OCI container
+ osTemplate?: string; // Template or OCI image used (e.g., "oci:docker.io/library/alpine:latest")
}
export interface DockerHost {
diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts
index 59dbf8a..24df59c 100644
--- a/frontend-modern/src/types/resource.ts
+++ b/frontend-modern/src/types/resource.ts
@@ -16,6 +16,7 @@ export type ResourceType =
| 'truenas' // TrueNAS system
| 'vm' // Proxmox VM
| 'container' // LXC container
+ | 'oci-container' // OCI container (Proxmox VE 9.1+)
| 'docker-container' // Docker container
| 'pod' // Kubernetes pod
| 'jail' // BSD jail
@@ -137,7 +138,7 @@ export function isInfrastructure(r: Resource): boolean {
}
export function isWorkload(r: Resource): boolean {
- return ['vm', 'container', 'docker-container', 'pod', 'jail'].includes(r.type);
+ return ['vm', 'container', 'oci-container', 'docker-container', 'pod', 'jail'].includes(r.type);
}
export function isStorage(r: Resource): boolean {
diff --git a/internal/models/models.go b/internal/models/models.go
index f12431c..75a026a 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -146,6 +146,9 @@ type Container struct {
IPAddresses []string `json:"ipAddresses,omitempty"`
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
OSName string `json:"osName,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 used (e.g., "docker:alpine:latest")
}
// Host represents a generic infrastructure host reporting via external agents.
diff --git a/internal/monitoring/container_parsing.go b/internal/monitoring/container_parsing.go
index c977de7..d904db2 100644
--- a/internal/monitoring/container_parsing.go
+++ b/internal/monitoring/container_parsing.go
@@ -490,3 +490,71 @@ func extractContainerOSType(config map[string]interface{}) string {
}
return ostype
}
+
+// extractContainerOSTemplate extracts the ostemplate value from container config.
+// This is the template used to create the container, which may be an LXC template
+// or an OCI image reference (Proxmox VE 9.1+).
+func extractContainerOSTemplate(config map[string]interface{}) string {
+ if len(config) == 0 {
+ return ""
+ }
+
+ // Try common field names for the template
+ for _, key := range []string{"ostemplate", "template"} {
+ if raw, ok := config[key]; ok {
+ if value := strings.TrimSpace(fmt.Sprint(raw)); value != "" {
+ return value
+ }
+ }
+ }
+
+ return ""
+}
+
+// isOCITemplate returns true if the ostemplate string indicates an OCI container image.
+// Proxmox VE 9.1+ supports pulling OCI images from registries like Docker Hub.
+// OCI templates typically have formats like:
+// - "oci:docker.io/library/alpine:latest"
+// - "docker:alpine:latest"
+// - "local:vztmpl/oci-alpine-latest.tar.gz" (pulled OCI image stored locally)
+func isOCITemplate(template string) bool {
+ if template == "" {
+ return false
+ }
+
+ template = strings.ToLower(strings.TrimSpace(template))
+
+ // Explicit OCI prefix
+ if strings.HasPrefix(template, "oci:") {
+ return true
+ }
+
+ // Docker Hub shorthand (docker:image:tag)
+ if strings.HasPrefix(template, "docker:") {
+ return true
+ }
+
+ // Check for common OCI registry URLs
+ ociRegistries := []string{
+ "docker.io/",
+ "ghcr.io/",
+ "gcr.io/",
+ "quay.io/",
+ "registry.hub.docker.com/",
+ "mcr.microsoft.com/",
+ "public.ecr.aws/",
+ }
+ for _, registry := range ociRegistries {
+ if strings.Contains(template, registry) {
+ return true
+ }
+ }
+
+ // Check for locally stored OCI images (typically have "oci" in the filename)
+ // e.g., "local:vztmpl/oci-alpine-3.18.tar.xz"
+ if strings.Contains(template, "/oci-") || strings.Contains(template, "/oci_") {
+ return true
+ }
+
+ return false
+}
diff --git a/internal/monitoring/container_parsing_test.go b/internal/monitoring/container_parsing_test.go
index fef1d18..a621ef0 100644
--- a/internal/monitoring/container_parsing_test.go
+++ b/internal/monitoring/container_parsing_test.go
@@ -1748,3 +1748,208 @@ func containersEqual(a, b *models.Container) bool {
}
return diskSlicesEqual(a.Disks, b.Disks)
}
+
+// OCI Container Detection Tests (Proxmox VE 9.1+)
+
+func TestExtractContainerOSTemplate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ config map[string]interface{}
+ expected string
+ }{
+ {
+ name: "empty config",
+ config: map[string]interface{}{},
+ expected: "",
+ },
+ {
+ name: "standard LXC template",
+ config: map[string]interface{}{
+ "ostemplate": "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
+ },
+ expected: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
+ },
+ {
+ name: "OCI image with oci prefix",
+ config: map[string]interface{}{
+ "ostemplate": "oci:docker.io/library/alpine:latest",
+ },
+ expected: "oci:docker.io/library/alpine:latest",
+ },
+ {
+ name: "Docker Hub shorthand",
+ config: map[string]interface{}{
+ "ostemplate": "docker:nginx:latest",
+ },
+ expected: "docker:nginx:latest",
+ },
+ {
+ name: "template field fallback",
+ config: map[string]interface{}{
+ "template": "oci:ghcr.io/myorg/myimage:v1.0",
+ },
+ expected: "oci:ghcr.io/myorg/myimage:v1.0",
+ },
+ {
+ name: "ostemplate takes precedence over template",
+ config: map[string]interface{}{
+ "ostemplate": "oci:docker.io/library/alpine:latest",
+ "template": "local:vztmpl/something-else.tar.gz",
+ },
+ expected: "oci:docker.io/library/alpine:latest",
+ },
+ {
+ name: "whitespace trimmed",
+ config: map[string]interface{}{
+ "ostemplate": " oci:docker.io/library/alpine:latest ",
+ },
+ expected: "oci:docker.io/library/alpine:latest",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result := extractContainerOSTemplate(tt.config)
+ if result != tt.expected {
+ t.Errorf("extractContainerOSTemplate() = %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestIsOCITemplate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ template string
+ expected bool
+ }{
+ // Empty/nil cases
+ {
+ name: "empty string",
+ template: "",
+ expected: false,
+ },
+
+ // Standard LXC templates (should NOT be detected as OCI)
+ {
+ name: "standard LXC template",
+ template: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst",
+ expected: false,
+ },
+ {
+ name: "Proxmox template store",
+ template: "local:vztmpl/debian-12-standard_12.0-1_amd64.tar.zst",
+ expected: false,
+ },
+
+ // Explicit OCI prefix
+ {
+ name: "oci prefix - Docker Hub",
+ template: "oci:docker.io/library/alpine:latest",
+ expected: true,
+ },
+ {
+ name: "oci prefix - GHCR",
+ template: "oci:ghcr.io/myorg/myimage:v1.0",
+ expected: true,
+ },
+ {
+ name: "oci prefix uppercase",
+ template: "OCI:docker.io/library/nginx:latest",
+ expected: true,
+ },
+
+ // Docker Hub shorthand
+ {
+ name: "docker prefix simple",
+ template: "docker:alpine:latest",
+ expected: true,
+ },
+ {
+ name: "docker prefix with path",
+ template: "docker:library/nginx:1.25",
+ expected: true,
+ },
+ {
+ name: "docker prefix uppercase",
+ template: "DOCKER:redis:7",
+ expected: true,
+ },
+
+ // Registry URLs embedded (with slashes as the detection logic expects)
+ {
+ name: "Docker Hub URL with slashes",
+ template: "docker.io/library/alpine:latest",
+ expected: true,
+ },
+ {
+ name: "GHCR URL with slashes",
+ template: "ghcr.io/myorg/myapp:v2",
+ expected: true,
+ },
+ {
+ name: "GCR URL",
+ template: "gcr.io/myproject/myimage:latest",
+ expected: true,
+ },
+ {
+ name: "Quay.io URL",
+ template: "quay.io/coreos/etcd:v3.5",
+ expected: true,
+ },
+ {
+ name: "Microsoft Container Registry",
+ template: "mcr.microsoft.com/dotnet/runtime:7.0",
+ expected: true,
+ },
+ {
+ name: "AWS ECR Public",
+ template: "public.ecr.aws/amazonlinux/amazonlinux:latest",
+ expected: true,
+ },
+
+ // Locally stored OCI images
+ {
+ name: "local OCI image with oci- prefix",
+ template: "local:vztmpl/oci-alpine-3.18.tar.xz",
+ expected: true,
+ },
+ {
+ name: "local OCI image with oci_ prefix",
+ template: "local:vztmpl/oci_nginx_latest.tar.gz",
+ expected: true,
+ },
+
+ // Edge cases
+ {
+ name: "case insensitive oci",
+ template: "OcI:docker.io/library/alpine:latest",
+ expected: true,
+ },
+ {
+ name: "whitespace handling",
+ template: " oci:docker.io/library/alpine:latest ",
+ expected: true,
+ },
+ {
+ name: "similar but not OCI - social.io",
+ template: "local:vztmpl/social.io-app.tar.gz",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ result := isOCITemplate(tt.template)
+ if result != tt.expected {
+ t.Errorf("isOCITemplate(%q) = %v, want %v", tt.template, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index ccf9c12..1baaa96 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -2483,6 +2483,21 @@ func (m *Monitor) enrichContainerMetadata(ctx context.Context, client PVEClientI
if osName := extractContainerOSType(configData); osName != "" {
container.OSName = osName
}
+ // Detect OCI containers (Proxmox VE 9.1+)
+ // OCI containers have ostemplate pointing to an OCI registry (e.g., "oci:docker.io/library/alpine:latest")
+ if osTemplate := extractContainerOSTemplate(configData); osTemplate != "" {
+ container.OSTemplate = osTemplate
+ // Check if this is an OCI container based on template format
+ if isOCITemplate(osTemplate) {
+ container.IsOCI = true
+ container.Type = "oci" // Override type from "lxc" to "oci"
+ log.Debug().
+ Str("container", container.Name).
+ Int("vmid", container.VMID).
+ Str("osTemplate", osTemplate).
+ Msg("Detected OCI container")
+ }
+ }
}
if len(addressOrder) == 0 {