diff --git a/homeassistant/components/portainer/diagnostics.py b/homeassistant/components/portainer/diagnostics.py new file mode 100644 index 00000000000..f95b75b294c --- /dev/null +++ b/homeassistant/components/portainer/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics for the Portainer integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from . import PortainerConfigEntry +from .coordinator import PortainerCoordinator + +TO_REDACT = [CONF_API_TOKEN] + + +def _serialize_coordinator(coordinator: PortainerCoordinator) -> dict[str, Any]: + """Serialize coordinator data into a JSON-safe structure.""" + + serialized_endpoints: list[dict[str, Any]] = [] + for endpoint_id, endpoint_data in coordinator.data.items(): + serialized_endpoints.append( + { + "id": endpoint_id, + "name": endpoint_data.name, + "endpoint": { + "status": endpoint_data.endpoint.status, + "url": endpoint_data.endpoint.url, + "public_url": endpoint_data.endpoint.public_url, + }, + "containers": [ + { + "id": container.id, + "names": list(container.names or []), + "image": container.image, + "state": container.state, + "status": container.status, + } + for container in endpoint_data.containers.values() + ], + } + ) + + return {"endpoints": serialized_endpoints} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: PortainerConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Portainer config entry.""" + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "coordinator": _serialize_coordinator(config_entry.runtime_data), + } diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml index d26f0087d87..d0f95bb8591 100644 --- a/homeassistant/components/portainer/quality_scale.yaml +++ b/homeassistant/components/portainer/quality_scale.yaml @@ -44,7 +44,7 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/portainer/snapshots/test_diagnostics.ambr b/tests/components/portainer/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8ce3040f4dc --- /dev/null +++ b/tests/components/portainer/snapshots/test_diagnostics.ambr @@ -0,0 +1,88 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_token': '**REDACTED**', + 'url': 'https://127.0.0.1:9000/', + 'verify_ssl': True, + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'portainer', + 'entry_id': 'portainer_test_entry_123', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Portainer test', + 'unique_id': None, + 'version': 2, + }), + 'coordinator': dict({ + 'endpoints': list([ + dict({ + 'containers': list([ + dict({ + 'id': 'aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', + 'image': 'docker.io/library/ubuntu:latest', + 'names': list([ + '/funny_chatelet', + ]), + 'state': 'running', + 'status': 'Up 4 days', + }), + dict({ + 'id': 'bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', + 'image': 'docker.io/library/nginx:latest', + 'names': list([ + '/serene_banach', + ]), + 'state': 'running', + 'status': 'Up 2 days', + }), + dict({ + 'id': 'cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', + 'image': 'docker.io/library/postgres:15', + 'names': list([ + '/stoic_turing', + ]), + 'state': 'running', + 'status': 'Up 1 day', + }), + dict({ + 'id': 'dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', + 'image': 'docker.io/library/redis:7', + 'names': list([ + '/focused_einstein', + ]), + 'state': 'running', + 'status': 'Up 12 hours', + }), + dict({ + 'id': 'ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', + 'image': 'docker.io/library/python:3.13-slim', + 'names': list([ + '/practical_morse', + ]), + 'state': 'running', + 'status': 'Up 6 hours', + }), + ]), + 'endpoint': dict({ + 'public_url': 'docker.mydomain.tld:2375', + 'status': 1, + 'url': 'docker.mydomain.tld:2375', + }), + 'id': 1, + 'name': 'my-environment', + }), + ]), + }), + }) +# --- diff --git a/tests/components/portainer/test_diagnostics.py b/tests/components/portainer/test_diagnostics.py new file mode 100644 index 00000000000..882d80570a7 --- /dev/null +++ b/tests/components/portainer/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the Portainer component diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + await setup_integration(hass, mock_config_entry) + + diagnostics_entry = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert diagnostics_entry == snapshot( + exclude=props("created_at", "modified_at"), + )