diff --git a/config/dashboards/infrastructure/partials/docker_containers_sections.yaml b/config/dashboards/infrastructure/partials/docker_containers_sections.yaml index 3a2c37b9..9ceed273 100644 --- a/config/dashboards/infrastructure/partials/docker_containers_sections.yaml +++ b/config/dashboards/infrastructure/partials/docker_containers_sections.yaml @@ -199,5 +199,8 @@ type: custom:button-card template: bearstone_infra_container_row icon: mdi:docker + exclude: + - state: unavailable + - state: unknown sort: method: name diff --git a/config/dashboards/infrastructure/templates/button_card_templates.yaml b/config/dashboards/infrastructure/templates/button_card_templates.yaml index d7c26ad3..48f936bf 100644 --- a/config/dashboards/infrastructure/templates/button_card_templates.yaml +++ b/config/dashboards/infrastructure/templates/button_card_templates.yaml @@ -614,7 +614,7 @@ bearstone_infra_container_row: } const switchEntity = key ? `switch.${key}_container` : ''; const switchEntityAlt = key ? `switch.${key}_container_2` : ''; - const monitored = states['switch.docker_monitored_containers']?.attributes?.entity_id; + const monitored = states['sensor.docker_monitored_switch_inventory']?.attributes?.entity_id; const restartCandidates = key ? [ `button.${key}_restart_container`, `button.${key}_restart_container_2`, diff --git a/config/packages/docker_infrastructure.yaml b/config/packages/docker_infrastructure.yaml index 73993f90..b3383e94 100644 --- a/config/packages/docker_infrastructure.yaml +++ b/config/packages/docker_infrastructure.yaml @@ -11,6 +11,7 @@ # Notes: Reboots are handled directly on each host by apt_weekly.sh. # Notes: Reboot staggering: docker_14 first, docker_69 second, docker_10 third. # Notes: Container monitoring is dynamic with binary_sensor status preferred over switch state. +# Notes: Weekly Joanna reconcile checks discovered container switches vs configured group members. # Notes: Includes Portainer stack status repairs, 20-minute Joanna dispatch for persistent container outages, and scheduled image prune. ###################################################################### @@ -203,18 +204,117 @@ template: {% endif %} - sensor: + - name: "Docker Monitored Switch Inventory" + unique_id: docker_monitored_switch_inventory + icon: mdi:docker + state: >- + {% set ns = namespace(items=[]) %} + {% for item in states.switch %} + {% set ent = item.entity_id %} + {% if ent is search('^switch\\..*_container(?:_2)?$') %} + {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} + {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 + or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + or expand('sensor.' ~ key ~ '_state') | count > 0 + or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set switch_state = states(ent) | lower %} + {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} + {% if ent not in ns.items %} + {% set ns.items = ns.items + [ent] %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {{ ns.items | sort | count }} + attributes: + entity_id: >- + {% set ns = namespace(items=[]) %} + {% for item in states.switch %} + {% set ent = item.entity_id %} + {% if ent is search('^switch\\..*_container(?:_2)?$') %} + {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} + {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 + or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + or expand('sensor.' ~ key ~ '_state') | count > 0 + or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set switch_state = states(ent) | lower %} + {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} + {% if ent not in ns.items %} + {% set ns.items = ns.items + [ent] %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {{ ns.items | sort }} + configured_group_members: >- + {{ state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list | sort }} + missing_from_group: >- + {% set discovered_ns = namespace(items=[]) %} + {% for item in states.switch %} + {% set ent = item.entity_id %} + {% if ent is search('^switch\\..*_container(?:_2)?$') %} + {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} + {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 + or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + or expand('sensor.' ~ key ~ '_state') | count > 0 + or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set switch_state = states(ent) | lower %} + {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} + {% if ent not in discovered_ns.items %} + {% set discovered_ns.items = discovered_ns.items + [ent] %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% set discovered = discovered_ns.items | list %} + {% set configured = state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list %} + {% set ns = namespace(items=[]) %} + {% for ent in discovered %} + {% if ent not in configured %} + {% set ns.items = ns.items + [ent] %} + {% endif %} + {% endfor %} + {{ ns.items | sort }} + stale_group_members: >- + {% set discovered_ns = namespace(items=[]) %} + {% for item in states.switch %} + {% set ent = item.entity_id %} + {% if ent is search('^switch\\..*_container(?:_2)?$') %} + {% set key = ent | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} + {% set has_companion = expand('binary_sensor.' ~ key ~ '_status') | count > 0 + or expand('binary_sensor.' ~ key ~ '_status_2') | count > 0 + or expand('sensor.' ~ key ~ '_state') | count > 0 + or expand('sensor.' ~ key ~ '_state_2') | count > 0 %} + {% set switch_state = states(ent) | lower %} + {% if has_companion or switch_state not in ['unknown', 'unavailable', ''] %} + {% if ent not in discovered_ns.items %} + {% set discovered_ns.items = discovered_ns.items + [ent] %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% set discovered = discovered_ns.items | list %} + {% set configured = state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list %} + {% set ns = namespace(items=[]) %} + {% for ent in configured %} + {% if ent not in discovered %} + {% set ns.items = ns.items + [ent] %} + {% endif %} + {% endfor %} + {{ ns.items | sort }} + - name: "Docker Monitored Container Count" unique_id: docker_monitored_container_count icon: mdi:format-list-numbered state: >- - {{ state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | count }} + {{ state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | count }} - name: "Docker Monitored Unavailable Count" unique_id: docker_monitored_unavailable_count icon: mdi:lan-disconnect state: >- {% set ns = namespace(keys=[], unavailable=0) %} - {% set monitored = state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) %} + {% set monitored = state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) %} {% for switch_entity in monitored %} {% set key = switch_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} {% if key not in ns.keys %} @@ -258,7 +358,7 @@ template: icon: mdi:docker state: >- {% set ns = namespace(keys=[], down=[]) %} - {% set monitored = state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list %} + {% set monitored = state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | list %} {% set telemetry_degraded = is_state('binary_sensor.docker_container_telemetry_degraded', 'on') %} {% for switch_entity in monitored %} {% set key = switch_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} @@ -302,7 +402,7 @@ template: attributes: down_containers: >- {% set ns = namespace(keys=[], down=[]) %} - {% set monitored = state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list %} + {% set monitored = state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | list %} {% set telemetry_degraded = is_state('binary_sensor.docker_container_telemetry_degraded', 'on') %} {% for switch_entity in monitored %} {% set key = switch_entity | replace('switch.', '') | regex_replace('_container(?:_2)?$', '') %} @@ -438,7 +538,7 @@ script: status_entity_alt: "binary_sensor.{{ container_key }}_status_2" state_entity: "sensor.{{ container_key }}_state" state_entity_alt: "sensor.{{ container_key }}_state_2" - monitored_switches: "{{ state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) }}" + monitored_switches: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) }}" tracked_container: "{{ switch_entity in monitored_switches or switch_entity_alt in monitored_switches }}" effective_entity: >- {% if expand(status_entity) | count > 0 %} @@ -799,7 +899,7 @@ automation: entity_id: "{{ trigger.event.data.entity_id | default('') }}" old_state: "{{ (trigger.event.data.old_state.state if trigger.event.data.old_state is not none else '') | lower }}" new_state: "{{ (trigger.event.data.new_state.state if trigger.event.data.new_state is not none else '') | lower }}" - monitored_switches: "{{ state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list }}" + monitored_switches: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | list }}" is_monitored_container_event: >- {% set ent = entity_id %} {% if ent.startswith('switch.') and (ent.endswith('_container') or ent.endswith('_container_2')) %} @@ -889,7 +989,7 @@ automation: minutes: "/55" action: - variables: - monitored_switches: "{{ state_attr('switch.docker_monitored_containers', 'entity_id') | default([], true) | list }}" + monitored_switches: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | list }}" - repeat: for_each: "{{ monitored_switches }}" sequence: @@ -1010,11 +1110,64 @@ automation: - service: homeassistant.update_entity target: entity_id: + - sensor.docker_monitored_switch_inventory - sensor.docker_monitored_unavailable_count - sensor.docker_containers_down_list - sensor.docker_containers_down_count - binary_sensor.docker_container_telemetry_degraded + - alias: "Docker Group Reconcile - Weekly Joanna Review" + id: docker_group_reconcile_weekly_joanna_review + description: "Weekly reconciliation of discovered Docker container entities vs configured group members." + mode: single + trigger: + - platform: time + at: "08:45:00" + condition: + - condition: time + weekday: + - sun + action: + - variables: + discovered_members: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'entity_id') | default([], true) | list }}" + configured_members: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'configured_group_members') | default([], true) | list }}" + missing_members: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'missing_from_group') | default([], true) | list }}" + stale_members: "{{ state_attr('sensor.docker_monitored_switch_inventory', 'stale_group_members') | default([], true) | list }}" + - service: script.send_to_logbook + data: + topic: "DOCKER" + message: >- + Weekly reconcile check: discovered={{ discovered_members | count }}, + configured={{ configured_members | count }}, + missing={{ missing_members | count }}, + stale={{ stale_members | count }}. + - choose: + - conditions: + - condition: template + value_template: "{{ (missing_members | count > 0) or (stale_members | count > 0) }}" + sequence: + - service: script.joanna_dispatch + data: + trigger_context: >- + HA automation docker_group_reconcile_weekly_joanna_review + (Docker Group Reconcile - Weekly Joanna Review) + source: "home_assistant_automation.docker_group_reconcile_weekly_joanna_review" + summary: >- + Docker group membership drift detected (missing={{ missing_members | count }}, + stale={{ stale_members | count }}). + entity_ids: + - sensor.docker_monitored_switch_inventory + - switch.docker_monitored_containers + diagnostics: >- + discovered={{ discovered_members | join(', ') if (discovered_members | count > 0) else 'none' }}; + configured={{ configured_members | join(', ') if (configured_members | count > 0) else 'none' }}; + missing={{ missing_members | join(', ') if (missing_members | count > 0) else 'none' }}; + stale={{ stale_members | join(', ') if (stale_members | count > 0) else 'none' }}. + request: >- + Reconcile Docker monitored group members in + config/packages/docker_infrastructure.yaml so configured monitoring + matches currently discovered container entities. + - alias: "Docker Weekly Prune Unused Images" id: docker_weekly_prune_unused_images description: "Run weekly unguarded prune actions across Docker hosts."