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:
parent
a20760f527
commit
bf43d448cf
14 changed files with 371 additions and 133 deletions
|
|
@ -908,6 +908,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
|
|
||||||
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||||
const guestType = guest.type === 'qemu' ? 'vm' : 'container';
|
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
|
// Toggle: remove if already in context, add if not
|
||||||
if (aiChatStore.hasContextItem(guestId)) {
|
if (aiChatStore.hasContextItem(guestId)) {
|
||||||
|
|
@ -918,14 +919,14 @@ export function Dashboard(props: DashboardProps) {
|
||||||
const contextData: Record<string, unknown> = {
|
const contextData: Record<string, unknown> = {
|
||||||
guestName: guest.name,
|
guestName: guest.name,
|
||||||
name: 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,
|
vmid: guest.vmid,
|
||||||
node: guest.node,
|
node: guest.node,
|
||||||
status: guest.status,
|
status: guest.status,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add OCI image info if available
|
// 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;
|
contextData.ociImage = guest.osTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -571,9 +571,15 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
|
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
|
||||||
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
|
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)
|
// OCI image info - extract clean image name from osTemplate (similar to Docker container image display)
|
||||||
const ociImage = createMemo(() => {
|
const ociImage = createMemo(() => {
|
||||||
if (props.guest.type !== 'oci') return null;
|
if (!isOCIContainer()) return null;
|
||||||
const template = (props.guest as Container).osTemplate;
|
const template = (props.guest as Container).osTemplate;
|
||||||
if (!template) return null;
|
if (!template) return null;
|
||||||
// Strip common prefixes to get clean image reference
|
// Strip common prefixes to get clean image reference
|
||||||
|
|
@ -903,19 +909,19 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
<span
|
<span
|
||||||
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
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'
|
? '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-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'
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
isVM(props.guest)
|
isVM(props.guest)
|
||||||
? 'Virtual Machine'
|
? 'Virtual Machine'
|
||||||
: props.guest.type === 'oci'
|
: isOCIContainer()
|
||||||
? `OCI Container${ociImage() ? ` • ${ociImage()}` : ''}`
|
? `OCI Container${ociImage() ? ` • ${ociImage()}` : ''}`
|
||||||
: 'LXC Container'
|
: 'LXC Container'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isVM(props.guest) ? 'VM' : props.guest.type === 'oci' ? 'OCI' : 'LXC'}
|
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -379,15 +379,15 @@ describe('UnifiedAgents platform commands', () => {
|
||||||
const generateButton = screen.getByRole('button', { name: /Generate token/i });
|
const generateButton = screen.getByRole('button', { name: /Generate token/i });
|
||||||
fireEvent.click(generateButton);
|
fireEvent.click(generateButton);
|
||||||
|
|
||||||
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
|
await waitFor(() => expect(createTokenMock).toHaveBeenCalled(), { interval: 0 });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument();
|
expect(screen.getByText('Docker monitoring')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Docker monitoring is disabled by default
|
// Docker monitoring is disabled by default
|
||||||
const checkbox = screen.getByRole('checkbox', { name: /Enable Docker monitoring/i });
|
const checkbox = screen.getByRole('checkbox', { name: /Docker monitoring/i });
|
||||||
expect(checkbox).not.toBeChecked();
|
expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
// Enable it
|
// Enable it
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
|
|
|
||||||
|
|
@ -240,29 +240,62 @@ describe('useResourcesAsLegacy - Legacy Format Conversion', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Container conversion', () => {
|
describe('Container conversion', () => {
|
||||||
it('converts Resource to legacy Container format', () => {
|
it('converts container Resource to legacy Container format', () => {
|
||||||
const resource = createMockResource({
|
const resource = createMockResource({
|
||||||
type: 'container',
|
type: 'container',
|
||||||
platformData: {
|
platformData: {
|
||||||
vmid: 200,
|
vmid: 200,
|
||||||
node: 'pve1',
|
node: 'pve1',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const platformData = resource.platformData as Record<string, unknown>;
|
const platformData = resource.platformData as Record<string, unknown>;
|
||||||
const legacyContainer = {
|
const isOCI = resource.type === 'oci-container' || platformData?.isOci === true || platformData?.type === 'oci';
|
||||||
id: resource.id,
|
const legacyContainer = {
|
||||||
vmid: platformData?.vmid as number,
|
id: resource.id,
|
||||||
name: resource.name,
|
vmid: platformData?.vmid as number,
|
||||||
type: 'lxc',
|
name: resource.name,
|
||||||
status: resource.status === 'running' ? 'running' : 'stopped',
|
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.vmid).toBe(200);
|
||||||
expect(legacyContainer.type).toBe('lxc');
|
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', () => {
|
describe('Node conversion', () => {
|
||||||
it('converts Resource to legacy Node format with temperature', () => {
|
it('converts Resource to legacy Node format with temperature', () => {
|
||||||
|
|
|
||||||
|
|
@ -304,8 +304,16 @@ export function useResourcesAsLegacy() {
|
||||||
return wsStore.state.containers ?? [];
|
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 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 {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10),
|
vmid: platformData?.vmid as number ?? parseInt(r.id.split('-').pop() ?? '0', 10),
|
||||||
|
|
@ -313,7 +321,9 @@ export function useResourcesAsLegacy() {
|
||||||
node: platformData?.node as string ?? '',
|
node: platformData?.node as string ?? '',
|
||||||
instance: platformData?.instance as string ?? r.platformId,
|
instance: platformData?.instance as string ?? r.platformId,
|
||||||
status: r.status === 'running' ? 'running' : 'stopped',
|
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
|
cpu: (r.cpu?.current ?? 0) / 100, // Convert from percentage to ratio for Dashboard
|
||||||
cpus: platformData?.cpus as number ?? 1,
|
cpus: platformData?.cpus as number ?? 1,
|
||||||
memory: r.memory ? {
|
memory: r.memory ? {
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,19 @@ func (c Container) ToFrontend() ContainerFrontend {
|
||||||
LastSeen: c.LastSeen.Unix() * 1000,
|
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
|
// Convert tags array to string
|
||||||
if len(c.Tags) > 0 {
|
if len(c.Tags) > 0 {
|
||||||
ct.Tags = strings.Join(c.Tags, ",")
|
ct.Tags = strings.Join(c.Tags, ",")
|
||||||
|
|
|
||||||
|
|
@ -267,42 +267,99 @@ func TestVMToFrontend_NegativeNetworkValues(t *testing.T) {
|
||||||
func TestContainerToFrontend(t *testing.T) {
|
func TestContainerToFrontend(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
container := Container{
|
t.Run("lxc", func(t *testing.T) {
|
||||||
ID: "ct-101",
|
container := Container{
|
||||||
VMID: 101,
|
ID: "ct-101",
|
||||||
Name: "test-ct",
|
VMID: 101,
|
||||||
Node: "pve1",
|
Name: "test-ct",
|
||||||
Status: "running",
|
Node: "pve1",
|
||||||
Type: "lxc",
|
Status: "running",
|
||||||
CPU: 0.10,
|
Type: "lxc",
|
||||||
CPUs: 2,
|
CPU: 0.10,
|
||||||
Memory: Memory{Total: 4000000000, Used: 2000000000},
|
CPUs: 2,
|
||||||
Disk: Disk{Total: 50000000000, Used: 25000000000},
|
Memory: Memory{Total: 4000000000, Used: 2000000000},
|
||||||
NetworkIn: 500000,
|
Disk: Disk{Total: 50000000000, Used: 25000000000},
|
||||||
NetworkOut: 250000,
|
NetworkIn: 500000,
|
||||||
Uptime: 7200,
|
NetworkOut: 250000,
|
||||||
Tags: []string{"dev", "database"},
|
Uptime: 7200,
|
||||||
LastSeen: now,
|
Tags: []string{"dev", "database"},
|
||||||
IPAddresses: []string{"192.168.1.51"},
|
LastSeen: now,
|
||||||
}
|
IPAddresses: []string{"192.168.1.51"},
|
||||||
|
}
|
||||||
|
|
||||||
frontend := container.ToFrontend()
|
frontend := container.ToFrontend()
|
||||||
|
|
||||||
if frontend.ID != container.ID {
|
if frontend.ID != container.ID {
|
||||||
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
|
t.Errorf("ID = %q, want %q", frontend.ID, container.ID)
|
||||||
}
|
}
|
||||||
if frontend.VMID != container.VMID {
|
if frontend.VMID != container.VMID {
|
||||||
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
|
t.Errorf("VMID = %d, want %d", frontend.VMID, container.VMID)
|
||||||
}
|
}
|
||||||
if frontend.Type != container.Type {
|
if frontend.Type != container.Type {
|
||||||
t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
|
t.Errorf("Type = %q, want %q", frontend.Type, container.Type)
|
||||||
}
|
}
|
||||||
if frontend.Tags != "dev,database" {
|
if frontend.IsOCI {
|
||||||
t.Errorf("Tags = %q, want 'dev,database'", frontend.Tags)
|
t.Errorf("IsOCI = %v, want false", frontend.IsOCI)
|
||||||
}
|
}
|
||||||
if frontend.Memory == nil {
|
if frontend.Tags != "dev,database" {
|
||||||
t.Error("Memory should not be nil when Total > 0")
|
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) {
|
func TestDockerContainerToFrontend(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ type ContainerFrontend struct {
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Type string `json:"type"`
|
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"`
|
CPU float64 `json:"cpu"`
|
||||||
CPUs int `json:"cpus"`
|
CPUs int `json:"cpus"`
|
||||||
Memory *Memory `json:"memory,omitempty"` // Full memory object
|
Memory *Memory `json:"memory,omitempty"` // Full memory object
|
||||||
|
|
|
||||||
|
|
@ -5517,6 +5517,22 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
return false
|
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 allVMs []models.VM
|
||||||
var allContainers []models.Container
|
var allContainers []models.Container
|
||||||
|
|
||||||
|
|
@ -6160,6 +6176,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
LastSeen: time.Now(),
|
LastSeen: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prevContainerIsOCI[container.VMID] {
|
||||||
|
container.IsOCI = true
|
||||||
|
container.Type = "oci"
|
||||||
|
}
|
||||||
|
|
||||||
// Parse tags
|
// Parse tags
|
||||||
if res.Tags != "" {
|
if res.Tags != "" {
|
||||||
container.Tags = strings.Split(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)
|
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)
|
allContainers = append(allContainers, container)
|
||||||
|
|
||||||
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{
|
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{
|
||||||
|
|
@ -6190,26 +6230,6 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
|
||||||
Raw: guestRaw,
|
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
|
// Check thresholds for alerts
|
||||||
m.alertManager.CheckGuest(container, instanceName)
|
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
|
// Preserve VMs and containers from nodes within grace period
|
||||||
// The cluster/resources endpoint doesn't return VMs/containers from nodes Proxmox considers offline,
|
// 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
|
// but we want to keep showing them if the node is within grace period
|
||||||
prevState := m.GetState()
|
|
||||||
|
|
||||||
// Count previous resources for this instance
|
// Count previous resources for this instance
|
||||||
prevVMCount := 0
|
prevVMCount := 0
|
||||||
prevContainerCount := 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
|
// Only update if the name or type has changed
|
||||||
if existing.LastKnownName != name || existing.LastKnownType != guestType {
|
if existing.LastKnownName != name || existing.LastKnownType != guestType {
|
||||||
existing.LastKnownName = name
|
existing.LastKnownName = name
|
||||||
|
|
|
||||||
|
|
@ -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().
|
log.Info().
|
||||||
Str("instance", instanceName).
|
Str("instance", instanceName).
|
||||||
Int("totalNodes", len(nodes)).
|
Int("totalNodes", len(nodes)).
|
||||||
|
|
@ -1026,6 +1042,11 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if prevContainerIsOCI[modelContainer.VMID] {
|
||||||
|
modelContainer.IsOCI = true
|
||||||
|
modelContainer.Type = "oci"
|
||||||
|
}
|
||||||
|
|
||||||
if override, ok := rootUsageOverrides[int(container.VMID)]; ok {
|
if override, ok := rootUsageOverrides[int(container.VMID)]; ok {
|
||||||
overrideUsed := clampToInt64(override.Used)
|
overrideUsed := clampToInt64(override.Used)
|
||||||
overrideTotal := clampToInt64(override.Total)
|
overrideTotal := clampToInt64(override.Total)
|
||||||
|
|
|
||||||
|
|
@ -219,11 +219,14 @@ func FromContainer(ct models.Container) Resource {
|
||||||
VMID: ct.VMID,
|
VMID: ct.VMID,
|
||||||
Node: ct.Node,
|
Node: ct.Node,
|
||||||
Instance: ct.Instance,
|
Instance: ct.Instance,
|
||||||
|
Type: ct.Type,
|
||||||
CPUs: ct.CPUs,
|
CPUs: ct.CPUs,
|
||||||
Template: ct.Template,
|
Template: ct.Template,
|
||||||
Lock: ct.Lock,
|
Lock: ct.Lock,
|
||||||
OSName: ct.OSName,
|
OSName: ct.OSName,
|
||||||
IPAddresses: ct.IPAddresses,
|
IPAddresses: ct.IPAddresses,
|
||||||
|
IsOCI: ct.IsOCI || strings.EqualFold(strings.TrimSpace(ct.Type), "oci"),
|
||||||
|
OSTemplate: ct.OSTemplate,
|
||||||
NetworkIn: ct.NetworkIn,
|
NetworkIn: ct.NetworkIn,
|
||||||
NetworkOut: ct.NetworkOut,
|
NetworkOut: ct.NetworkOut,
|
||||||
DiskRead: ct.DiskRead,
|
DiskRead: ct.DiskRead,
|
||||||
|
|
@ -235,9 +238,14 @@ func FromContainer(ct models.Container) Resource {
|
||||||
// Parent is the node - format matches Node.ID: instance-nodename
|
// Parent is the node - format matches Node.ID: instance-nodename
|
||||||
parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node)
|
parentID := fmt.Sprintf("%s-%s", ct.Instance, ct.Node)
|
||||||
|
|
||||||
|
resourceType := ResourceTypeContainer
|
||||||
|
if platformData.IsOCI {
|
||||||
|
resourceType = ResourceTypeOCIContainer
|
||||||
|
}
|
||||||
|
|
||||||
return Resource{
|
return Resource{
|
||||||
ID: ct.ID,
|
ID: ct.ID,
|
||||||
Type: ResourceTypeContainer,
|
Type: resourceType,
|
||||||
Name: ct.Name,
|
Name: ct.Name,
|
||||||
PlatformID: ct.Instance,
|
PlatformID: ct.Instance,
|
||||||
PlatformType: PlatformProxmoxPVE,
|
PlatformType: PlatformProxmoxPVE,
|
||||||
|
|
|
||||||
|
|
@ -199,47 +199,99 @@ func TestFromVM(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromContainer(t *testing.T) {
|
func TestFromContainer(t *testing.T) {
|
||||||
ct := models.Container{
|
now := time.Now()
|
||||||
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(),
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
r := FromContainer(ct)
|
||||||
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 r.Type != ResourceTypeContainer {
|
||||||
if err := r.GetPlatformData(&pd); err != nil {
|
t.Errorf("Expected type %s, got %s", ResourceTypeContainer, r.Type)
|
||||||
t.Fatalf("Failed to get platform data: %v", err)
|
}
|
||||||
}
|
if r.Status != StatusStopped {
|
||||||
if len(pd.IPAddresses) != 1 || pd.IPAddresses[0] != "192.168.1.50" {
|
t.Errorf("Expected status %s, got %s", StatusStopped, r.Status)
|
||||||
t.Errorf("Expected IP 192.168.1.50, got %v", pd.IPAddresses)
|
}
|
||||||
}
|
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) {
|
func TestFromHost(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -53,16 +53,20 @@ type VMPlatformData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerPlatformData contains Proxmox LXC container-specific fields.
|
// 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 {
|
type ContainerPlatformData struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Node string `json:"node"` // Proxmox node hosting this container
|
Node string `json:"node"` // Proxmox node hosting this container
|
||||||
Instance string `json:"instance"` // Proxmox instance URL
|
Instance string `json:"instance"` // Proxmox instance URL
|
||||||
|
Type string `json:"type,omitempty"` // lxc or oci
|
||||||
CPUs int `json:"cpus"` // Number of vCPUs
|
CPUs int `json:"cpus"` // Number of vCPUs
|
||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
Lock string `json:"lock,omitempty"`
|
Lock string `json:"lock,omitempty"`
|
||||||
OSName string `json:"osName,omitempty"`
|
OSName string `json:"osName,omitempty"`
|
||||||
IPAddresses []string `json:"ipAddresses,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
|
// I/O stats
|
||||||
NetworkIn int64 `json:"networkIn"`
|
NetworkIn int64 `json:"networkIn"`
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ const (
|
||||||
// Compute Workloads - individual running instances
|
// Compute Workloads - individual running instances
|
||||||
ResourceTypeVM ResourceType = "vm" // Proxmox VM
|
ResourceTypeVM ResourceType = "vm" // Proxmox VM
|
||||||
ResourceTypeContainer ResourceType = "container" // LXC container
|
ResourceTypeContainer ResourceType = "container" // LXC container
|
||||||
|
ResourceTypeOCIContainer ResourceType = "oci-container" // OCI container (Proxmox VE 9.1+)
|
||||||
ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container
|
ResourceTypeDockerContainer ResourceType = "docker-container" // Docker container
|
||||||
ResourceTypePod ResourceType = "pod" // Kubernetes pod
|
ResourceTypePod ResourceType = "pod" // Kubernetes pod
|
||||||
ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail
|
ResourceTypeJail ResourceType = "jail" // BSD jail / TrueNAS jail
|
||||||
|
|
@ -201,7 +202,7 @@ func (r *Resource) IsInfrastructure() bool {
|
||||||
// rather than infrastructure.
|
// rather than infrastructure.
|
||||||
func (r *Resource) IsWorkload() bool {
|
func (r *Resource) IsWorkload() bool {
|
||||||
switch r.Type {
|
switch r.Type {
|
||||||
case ResourceTypeVM, ResourceTypeContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail:
|
case ResourceTypeVM, ResourceTypeContainer, ResourceTypeOCIContainer, ResourceTypeDockerContainer, ResourceTypePod, ResourceTypeJail:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue