Skip to content

Commit 4e48c88

Browse files
erwindounaCopilot
andauthored
Portainer add resource usage of containers (#155113)
Co-authored-by: Copilot <[email protected]>
1 parent af8cd04 commit 4e48c88

File tree

12 files changed

+1637
-252
lines changed

12 files changed

+1637
-252
lines changed

homeassistant/components/portainer/binary_sensor.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44

55
from collections.abc import Callable
66
from dataclasses import dataclass
7-
from typing import Any
8-
9-
from pyportainer.models.docker import DockerContainer
107

118
from homeassistant.components.binary_sensor import (
129
BinarySensorDeviceClass,
@@ -18,7 +15,7 @@
1815
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1916

2017
from . import PortainerConfigEntry
21-
from .coordinator import PortainerCoordinator
18+
from .coordinator import PortainerContainerData, PortainerCoordinator
2219
from .entity import (
2320
PortainerContainerEntity,
2421
PortainerCoordinatorData,
@@ -27,24 +24,31 @@
2724

2825

2926
@dataclass(frozen=True, kw_only=True)
30-
class PortainerBinarySensorEntityDescription(BinarySensorEntityDescription):
31-
"""Class to hold Portainer binary sensor description."""
27+
class PortainerContainerBinarySensorEntityDescription(BinarySensorEntityDescription):
28+
"""Class to hold Portainer container binary sensor description."""
29+
30+
state_fn: Callable[[PortainerContainerData], bool | None]
31+
32+
33+
@dataclass(frozen=True, kw_only=True)
34+
class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescription):
35+
"""Class to hold Portainer endpoint binary sensor description."""
3236

33-
state_fn: Callable[[Any], bool]
37+
state_fn: Callable[[PortainerCoordinatorData], bool | None]
3438

3539

36-
CONTAINER_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
37-
PortainerBinarySensorEntityDescription(
40+
CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = (
41+
PortainerContainerBinarySensorEntityDescription(
3842
key="status",
3943
translation_key="status",
40-
state_fn=lambda data: data.state == "running",
44+
state_fn=lambda data: data.container.state == "running",
4145
device_class=BinarySensorDeviceClass.RUNNING,
4246
entity_category=EntityCategory.DIAGNOSTIC,
4347
),
4448
)
4549

46-
ENDPOINT_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
47-
PortainerBinarySensorEntityDescription(
50+
ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = (
51+
PortainerEndpointBinarySensorEntityDescription(
4852
key="status",
4953
translation_key="status",
5054
state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped
@@ -76,7 +80,7 @@ def _async_add_new_endpoints(endpoints: list[PortainerCoordinatorData]) -> None:
7680
)
7781

7882
def _async_add_new_containers(
79-
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
83+
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
8084
) -> None:
8185
"""Add new container binary sensors."""
8286
async_add_entities(
@@ -113,12 +117,12 @@ def _async_add_new_containers(
113117
class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
114118
"""Representation of a Portainer endpoint binary sensor entity."""
115119

116-
entity_description: PortainerBinarySensorEntityDescription
120+
entity_description: PortainerEndpointBinarySensorEntityDescription
117121

118122
def __init__(
119123
self,
120124
coordinator: PortainerCoordinator,
121-
entity_description: PortainerBinarySensorEntityDescription,
125+
entity_description: PortainerEndpointBinarySensorEntityDescription,
122126
device_info: PortainerCoordinatorData,
123127
) -> None:
124128
"""Initialize Portainer endpoint binary sensor entity."""
@@ -141,13 +145,13 @@ def is_on(self) -> bool | None:
141145
class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
142146
"""Representation of a Portainer container sensor."""
143147

144-
entity_description: PortainerBinarySensorEntityDescription
148+
entity_description: PortainerContainerBinarySensorEntityDescription
145149

146150
def __init__(
147151
self,
148152
coordinator: PortainerCoordinator,
149-
entity_description: PortainerBinarySensorEntityDescription,
150-
device_info: DockerContainer,
153+
entity_description: PortainerContainerBinarySensorEntityDescription,
154+
device_info: PortainerContainerData,
151155
via_device: PortainerCoordinatorData,
152156
) -> None:
153157
"""Initialize the Portainer container sensor."""
@@ -164,6 +168,4 @@ def available(self) -> bool:
164168
@property
165169
def is_on(self) -> bool | None:
166170
"""Return true if the binary sensor is on."""
167-
return self.entity_description.state_fn(
168-
self.coordinator.data[self.endpoint_id].containers[self.device_name]
169-
)
171+
return self.entity_description.state_fn(self.container_data)

homeassistant/components/portainer/button.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
PortainerConnectionError,
1313
PortainerTimeoutError,
1414
)
15-
from pyportainer.models.docker import DockerContainer
1615

1716
from homeassistant.components.button import (
1817
ButtonDeviceClass,
@@ -26,7 +25,11 @@
2625

2726
from . import PortainerConfigEntry
2827
from .const import DOMAIN
29-
from .coordinator import PortainerCoordinator, PortainerCoordinatorData
28+
from .coordinator import (
29+
PortainerContainerData,
30+
PortainerCoordinator,
31+
PortainerCoordinatorData,
32+
)
3033
from .entity import PortainerContainerEntity
3134

3235

@@ -64,7 +67,7 @@ async def async_setup_entry(
6467
coordinator = entry.runtime_data
6568

6669
def _async_add_new_containers(
67-
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
70+
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
6871
) -> None:
6972
"""Add new container button sensors."""
7073
async_add_entities(
@@ -97,7 +100,7 @@ def __init__(
97100
self,
98101
coordinator: PortainerCoordinator,
99102
entity_description: PortainerButtonDescription,
100-
device_info: DockerContainer,
103+
device_info: PortainerContainerData,
101104
via_device: PortainerCoordinatorData,
102105
) -> None:
103106
"""Initialize the Portainer button entity."""

homeassistant/components/portainer/coordinator.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from collections.abc import Callable
67
from dataclasses import dataclass
78
from datetime import timedelta
@@ -13,7 +14,7 @@
1314
PortainerConnectionError,
1415
PortainerTimeoutError,
1516
)
16-
from pyportainer.models.docker import DockerContainer
17+
from pyportainer.models.docker import DockerContainer, DockerContainerStats
1718
from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
1819
from pyportainer.models.portainer import Endpoint
1920

@@ -39,11 +40,20 @@ class PortainerCoordinatorData:
3940
id: int
4041
name: str | None
4142
endpoint: Endpoint
42-
containers: dict[str, DockerContainer]
43+
containers: dict[str, PortainerContainerData]
4344
docker_version: DockerVersion
4445
docker_info: DockerInfo
4546

4647

48+
@dataclass(slots=True)
49+
class PortainerContainerData:
50+
"""Container data held by the Portainer coordinator."""
51+
52+
container: DockerContainer
53+
stats: DockerContainerStats
54+
stats_pre: DockerContainerStats | None
55+
56+
4757
class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]):
4858
"""Data Update Coordinator for Portainer."""
4959

@@ -72,7 +82,9 @@ def __init__(
7282
Callable[[list[PortainerCoordinatorData]], None]
7383
] = []
7484
self.new_containers_callbacks: list[
75-
Callable[[list[tuple[PortainerCoordinatorData, DockerContainer]]], None]
85+
Callable[
86+
[list[tuple[PortainerCoordinatorData, PortainerContainerData]]], None
87+
]
7688
] = []
7789

7890
async def _async_setup(self) -> None:
@@ -119,8 +131,6 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
119131
translation_key="cannot_connect",
120132
translation_placeholders={"error": repr(err)},
121133
) from err
122-
else:
123-
_LOGGER.debug("Fetched endpoints: %s", endpoints)
124134

125135
mapped_endpoints: dict[int, PortainerCoordinatorData] = {}
126136
for endpoint in endpoints:
@@ -136,6 +146,47 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
136146
containers = await self.portainer.get_containers(endpoint.id)
137147
docker_version = await self.portainer.docker_version(endpoint.id)
138148
docker_info = await self.portainer.docker_info(endpoint.id)
149+
150+
container_map: dict[str, PortainerContainerData] = {}
151+
152+
container_stats_task = [
153+
(
154+
container,
155+
self.portainer.container_stats(
156+
endpoint_id=endpoint.id,
157+
container_id=container.id,
158+
),
159+
)
160+
for container in containers
161+
]
162+
163+
container_stats_gather = await asyncio.gather(
164+
*[task for _, task in container_stats_task],
165+
)
166+
for (container, _), container_stats in zip(
167+
container_stats_task, container_stats_gather, strict=False
168+
):
169+
container_name = container.names[0].replace("/", " ").strip()
170+
171+
# Store previous stats if available. This is used to calculate deltas for CPU and network usage
172+
# In the first call it will be None, since it has nothing to compare with
173+
# Added a walrus pattern to check if not None on prev_container, to keep mypy happy. :)
174+
container_map[container_name] = PortainerContainerData(
175+
container=container,
176+
stats=container_stats,
177+
stats_pre=(
178+
prev_container.stats
179+
if self.data
180+
and (prev_data := self.data.get(endpoint.id)) is not None
181+
and (
182+
prev_container := prev_data.containers.get(
183+
container_name
184+
)
185+
)
186+
is not None
187+
else None
188+
),
189+
)
139190
except PortainerConnectionError as err:
140191
_LOGGER.exception("Connection error")
141192
raise UpdateFailed(
@@ -155,10 +206,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]:
155206
id=endpoint.id,
156207
name=endpoint.name,
157208
endpoint=endpoint,
158-
containers={
159-
container.names[0].replace("/", " ").strip(): container
160-
for container in containers
161-
},
209+
containers=container_map,
162210
docker_version=docker_version,
163211
docker_info=docker_info,
164212
)
@@ -179,7 +227,7 @@ def _async_add_remove_endpoints(
179227

180228
# Surprise, we also handle containers here :)
181229
current_containers = {
182-
(endpoint.id, container.id)
230+
(endpoint.id, container.container.id)
183231
for endpoint in mapped_endpoints.values()
184232
for container in endpoint.containers.values()
185233
}

homeassistant/components/portainer/diagnostics.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ def _serialize_coordinator(coordinator: PortainerCoordinator) -> dict[str, Any]:
3030
},
3131
"containers": [
3232
{
33-
"id": container.id,
34-
"names": list(container.names or []),
35-
"image": container.image,
36-
"state": container.state,
37-
"status": container.status,
33+
"id": container.container.id,
34+
"names": list(container.container.names or []),
35+
"image": container.container.image,
36+
"state": container.container.state,
37+
"status": container.container.status,
3838
}
3939
for container in endpoint_data.containers.values()
4040
],

homeassistant/components/portainer/entity.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Base class for Portainer entities."""
22

3-
from pyportainer.models.docker import DockerContainer
43
from yarl import URL
54

65
from homeassistant.const import CONF_URL
76
from homeassistant.helpers.device_registry import DeviceInfo
87
from homeassistant.helpers.update_coordinator import CoordinatorEntity
98

109
from .const import DEFAULT_NAME, DOMAIN
11-
from .coordinator import PortainerCoordinator, PortainerCoordinatorData
10+
from .coordinator import (
11+
PortainerContainerData,
12+
PortainerCoordinator,
13+
PortainerCoordinatorData,
14+
)
1215

1316

1417
class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]):
@@ -47,21 +50,22 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
4750

4851
def __init__(
4952
self,
50-
device_info: DockerContainer,
53+
device_info: PortainerContainerData,
5154
coordinator: PortainerCoordinator,
5255
via_device: PortainerCoordinatorData,
5356
) -> None:
5457
"""Initialize a Portainer container."""
5558
super().__init__(coordinator)
5659
self._device_info = device_info
57-
self.device_id = self._device_info.id
60+
self.device_id = self._device_info.container.id
5861
self.endpoint_id = via_device.endpoint.id
5962

6063
# Container ID's are ephemeral, so use the container name for the unique ID
6164
# The first one, should always be unique, it's fine if users have aliases
6265
# According to Docker's API docs, the first name is unique
63-
assert self._device_info.names, "Container names list unexpectedly empty"
64-
self.device_name = self._device_info.names[0].replace("/", " ").strip()
66+
names = self._device_info.container.names
67+
assert names, "Container names list unexpectedly empty"
68+
self.device_name = names[0].replace("/", " ").strip()
6569

6670
self._attr_device_info = DeviceInfo(
6771
identifiers={
@@ -79,3 +83,8 @@ def __init__(
7983
),
8084
translation_key=None if self.device_name else "unknown_container",
8185
)
86+
87+
@property
88+
def container_data(self) -> PortainerContainerData:
89+
"""Return the coordinator data for this container."""
90+
return self.coordinator.data[self.endpoint_id].containers[self.device_name]

homeassistant/components/portainer/icons.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"cpu_total": {
2323
"default": "mdi:cpu-64-bit"
2424
},
25+
"cpu_usage_total": {
26+
"default": "mdi:cpu-64-bit"
27+
},
2528
"docker_version": {
2629
"default": "mdi:docker"
2730
},
@@ -34,9 +37,18 @@
3437
"kernel_version": {
3538
"default": "mdi:memory"
3639
},
40+
"memory_limit": {
41+
"default": "mdi:memory"
42+
},
3743
"memory_total": {
3844
"default": "mdi:memory"
3945
},
46+
"memory_usage": {
47+
"default": "mdi:memory"
48+
},
49+
"memory_usage_percentage": {
50+
"default": "mdi:memory"
51+
},
4052
"operating_system": {
4153
"default": "mdi:chip"
4254
},

0 commit comments

Comments
 (0)