From 78328f3e74a2c5fcba41bfa34aa5b787fe12334c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:25:05 +0000 Subject: [PATCH 1/5] Initial plan From f78b936670bbccf6f071d657962ee48800058e5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:35:31 +0000 Subject: [PATCH 2/5] feat: show failure marker in SSH Docker panel for setup_error entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a container configured through SSH Docker is removed from the remote host, its config entry enters the `setup_error` state and the failure is only visible on the HA integrations dashboard. This change adds a "Failed to set up" section at the top of the SSH Docker panel for every entry in that state: - `_refreshFailedEntries()` fetches SSH Docker config entries via `hass.callApi` and filters for `state === "setup_error"`. Throttled to one API call per 60 s; called on panel connect, tab visibility restore, and on hass state changes. - `_renderFailedSection()` renders a red card per failed entry showing the entry title, an "⚠ Setup failed" badge, a hint text, and an "Open settings" button that navigates to the integrations page. - New CSS classes and translation keys added for all new UI elements. Agent-Logs-Url: https://github.com/gensyn/ssh_docker/sessions/af576b76-d10a-465d-8fe2-efef6756877e Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- frontend/ssh-docker-panel.js | 81 +++++++++++++++++++++++++++++++++++- strings.json | 6 ++- translations/de.json | 6 ++- translations/en.json | 6 ++- 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/frontend/ssh-docker-panel.js b/frontend/ssh-docker-panel.js index 4a30ae2..df2b37f 100644 --- a/frontend/ssh-docker-panel.js +++ b/frontend/ssh-docker-panel.js @@ -9,15 +9,22 @@ class SshDockerPanel extends HTMLElement { this._narrow = false; this._lastSnapshot = null; this._collapsedHosts = new Set(); + this._failedEntries = []; + this._failedEntriesFetchedAt = 0; } set hass(hass) { this._hass = hass; // Only re-render when SSH Docker entity states/attributes actually changed. const snapshot = this._sshDockerSnapshot(hass); - if (snapshot === this._lastSnapshot) return; + if (snapshot === this._lastSnapshot) { + // Even without entity changes, periodically re-check for failed entries. + this._refreshFailedEntries(); + return; + } this._lastSnapshot = snapshot; this._render(); + this._refreshFailedEntries(); } set narrow(value) { @@ -38,7 +45,9 @@ class SshDockerPanel extends HTMLElement { this._visibilityHandler = () => { if (document.visibilityState === "visible" && this._hass) { this._lastSnapshot = null; // force re-render + this._failedEntriesFetchedAt = 0; // force re-fetch of failed entries this._render(); + this._refreshFailedEntries(); } }; document.addEventListener("visibilitychange", this._visibilityHandler); @@ -48,7 +57,9 @@ class SshDockerPanel extends HTMLElement { this._pageshowHandler = (e) => { if (e.persisted && this._hass) { this._lastSnapshot = null; + this._failedEntriesFetchedAt = 0; this._render(); + this._refreshFailedEntries(); } }; window.addEventListener("pageshow", this._pageshowHandler); @@ -70,6 +81,7 @@ class SshDockerPanel extends HTMLElement { this._lastSnapshot = null; } this._render(); + this._refreshFailedEntries(); } disconnectedCallback() { @@ -97,6 +109,53 @@ class SshDockerPanel extends HTMLElement { .join(";"); } + // Fetches SSH Docker config entries and updates _failedEntries with those that + // have a setup_error state. Throttled to at most one API call per 60 seconds. + // Re-renders the panel when the failed entries list changes. + async _refreshFailedEntries() { + if (!this._hass) return; + const now = Date.now(); + if (now - this._failedEntriesFetchedAt < 60000) return; + this._failedEntriesFetchedAt = now; + try { + const entries = await this._hass.callApi("GET", "config/config_entries/entry?domain=ssh_docker"); + const failed = (entries || []).filter((e) => e.state === "setup_error"); + const changed = JSON.stringify(failed) !== JSON.stringify(this._failedEntries); + this._failedEntries = failed; + if (changed) this._render(); + } catch (_err) { + // Silently ignore – the failed-entries section is non-critical. + } + } + + // Returns the HTML for the "failed entries" section shown above the container grid. + _renderFailedSection() { + if (!this._failedEntries || this._failedEntries.length === 0) return ""; + const cards = this._failedEntries.map((entry) => ` +
+
+ ${entry.title} + ${this._t("setup_failed_badge")} +
+
+

${this._t("setup_failed_hint")}

+
+ +
+
+
+ `).join(""); + return ` +
+

⚠ ${this._t("setup_failed_section")}

+
${cards}
+
+ `; + } + _t(key) { return (this._hass && this._hass.localize(`component.ssh_docker.entity.ui.${key}.name`)) || key; } @@ -507,12 +566,27 @@ class SshDockerPanel extends HTMLElement { color: var(--secondary-text-color, #727272); font-style: italic; } + .failed-entries-section { + margin-bottom: 24px; + } + .failed-section-title { + margin: 0 0 12px 0; + font-size: 1rem; + color: #c0392b; + } + .setup-failed-hint { + font-size: 0.85em; + color: var(--secondary-text-color, #727272); + margin: 4px 0 8px; + } + .open-settings-btn { background: #c0392b; }
${this._narrow ? "" : ""}
SSH Docker
+ ${this._renderFailedSection()}
${filterButtons}
${hostFilterHtml} ${hostsHtml} @@ -553,6 +627,11 @@ class SshDockerPanel extends HTMLElement { btn.addEventListener("click", () => this._showLogs(btn.dataset.entity) ); + } else if (btn.classList.contains("open-settings-btn")) { + btn.addEventListener("click", () => { + history.pushState(null, "", btn.dataset.href); + window.dispatchEvent(new CustomEvent("location-changed", { bubbles: true, composed: true })); + }); } else { btn.addEventListener("click", () => this._handleAction(btn.dataset.action, btn.dataset.entity) diff --git a/strings.json b/strings.json index 8783fcd..71921a2 100644 --- a/strings.json +++ b/strings.json @@ -202,7 +202,11 @@ "all_states": { "name": "All states" }, "updates_filter": { "name": "⬆ Updates" }, "all_hosts": { "name": "All Hosts" }, - "no_containers": { "name": "No Docker containers found." } + "no_containers": { "name": "No Docker containers found." }, + "setup_failed_badge": { "name": "⚠ Setup failed" }, + "setup_failed_section": { "name": "Failed to set up" }, + "setup_failed_hint": { "name": "The container may have been removed from the remote host." }, + "btn_open_settings": { "name": "Open settings" } } }, "exceptions": { diff --git a/translations/de.json b/translations/de.json index 53b27fe..c28d39c 100644 --- a/translations/de.json +++ b/translations/de.json @@ -202,7 +202,11 @@ "all_states": { "name": "Alle Zustände" }, "updates_filter": { "name": "⬆ Updates" }, "all_hosts": { "name": "Alle Hosts" }, - "no_containers": { "name": "Keine Docker-Container gefunden." } + "no_containers": { "name": "Keine Docker-Container gefunden." }, + "setup_failed_badge": { "name": "⚠ Einrichtung fehlgeschlagen" }, + "setup_failed_section": { "name": "Einrichtung fehlgeschlagen" }, + "setup_failed_hint": { "name": "Der Container wurde möglicherweise vom entfernten Host entfernt." }, + "btn_open_settings": { "name": "Einstellungen öffnen" } } }, "exceptions": { diff --git a/translations/en.json b/translations/en.json index 8783fcd..71921a2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -202,7 +202,11 @@ "all_states": { "name": "All states" }, "updates_filter": { "name": "⬆ Updates" }, "all_hosts": { "name": "All Hosts" }, - "no_containers": { "name": "No Docker containers found." } + "no_containers": { "name": "No Docker containers found." }, + "setup_failed_badge": { "name": "⚠ Setup failed" }, + "setup_failed_section": { "name": "Failed to set up" }, + "setup_failed_hint": { "name": "The container may have been removed from the remote host." }, + "btn_open_settings": { "name": "Open settings" } } }, "exceptions": { From 946859fcceac59ff8c91a857dc650ee85d75b046 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:59:18 +0000 Subject: [PATCH 3/5] refactor: remove periodic refresh; fix Unknown host for failed entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the 60s throttle timer from _refreshFailedEntries(); replace with a simple concurrent-fetch guard (_isFetchingFailedEntries). _refreshFailedEntries() is no longer called from set hass() — only from connectedCallback(), visibilitychange, and pageshow handlers. - Fix "Unknown host": when a sensor entity still lives in hass.states for a setup_error config entry it would appear under "Unknown host" in the regular container grid (empty host attribute). Fix by: * _getEntityByEntryId(): single entity-registry loop that maps config entry_id → hass.states entity; shared by both callers. * _getFailedEntityIds(): derives its Set from _getEntityByEntryId() to avoid duplicating the loop. * _getAllContainers(): excludes entities belonging to failed entries. * _renderFailedSection(): shows host (from entity attributes) as a plain
when available. Agent-Logs-Url: https://github.com/gensyn/ssh_docker/sessions/78d9b250-12e0-4bb7-ac12-618b5effedfe Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- frontend/ssh-docker-panel.js | 75 ++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/frontend/ssh-docker-panel.js b/frontend/ssh-docker-panel.js index df2b37f..6f6db8e 100644 --- a/frontend/ssh-docker-panel.js +++ b/frontend/ssh-docker-panel.js @@ -10,21 +10,16 @@ class SshDockerPanel extends HTMLElement { this._lastSnapshot = null; this._collapsedHosts = new Set(); this._failedEntries = []; - this._failedEntriesFetchedAt = 0; + this._isFetchingFailedEntries = false; } set hass(hass) { this._hass = hass; // Only re-render when SSH Docker entity states/attributes actually changed. const snapshot = this._sshDockerSnapshot(hass); - if (snapshot === this._lastSnapshot) { - // Even without entity changes, periodically re-check for failed entries. - this._refreshFailedEntries(); - return; - } + if (snapshot === this._lastSnapshot) return; this._lastSnapshot = snapshot; this._render(); - this._refreshFailedEntries(); } set narrow(value) { @@ -45,7 +40,6 @@ class SshDockerPanel extends HTMLElement { this._visibilityHandler = () => { if (document.visibilityState === "visible" && this._hass) { this._lastSnapshot = null; // force re-render - this._failedEntriesFetchedAt = 0; // force re-fetch of failed entries this._render(); this._refreshFailedEntries(); } @@ -57,7 +51,6 @@ class SshDockerPanel extends HTMLElement { this._pageshowHandler = (e) => { if (e.persisted && this._hass) { this._lastSnapshot = null; - this._failedEntriesFetchedAt = 0; this._render(); this._refreshFailedEntries(); } @@ -110,13 +103,11 @@ class SshDockerPanel extends HTMLElement { } // Fetches SSH Docker config entries and updates _failedEntries with those that - // have a setup_error state. Throttled to at most one API call per 60 seconds. + // have a setup_error state. A simple guard prevents concurrent fetches. // Re-renders the panel when the failed entries list changes. async _refreshFailedEntries() { - if (!this._hass) return; - const now = Date.now(); - if (now - this._failedEntriesFetchedAt < 60000) return; - this._failedEntriesFetchedAt = now; + if (!this._hass || this._isFetchingFailedEntries) return; + this._isFetchingFailedEntries = true; try { const entries = await this._hass.callApi("GET", "config/config_entries/entry?domain=ssh_docker"); const failed = (entries || []).filter((e) => e.state === "setup_error"); @@ -125,19 +116,34 @@ class SshDockerPanel extends HTMLElement { if (changed) this._render(); } catch (_err) { // Silently ignore – the failed-entries section is non-critical. + } finally { + this._isFetchingFailedEntries = false; } } // Returns the HTML for the "failed entries" section shown above the container grid. + // For each failed entry, host info is sourced from a still-registered sensor entity + // (via hass.entities entity-registry lookup) when available. _renderFailedSection() { if (!this._failedEntries || this._failedEntries.length === 0) return ""; - const cards = this._failedEntries.map((entry) => ` + + // Build a map from config entry_id → sensor entity state (if it exists) + const entityByEntryId = this._getEntityByEntryId(); + + const cards = this._failedEntries.map((entry) => { + const sensorEntity = entityByEntryId[entry.entry_id]; + const host = (sensorEntity && sensorEntity.attributes && sensorEntity.attributes.host) || ""; + const hostHtml = host + ? `
${this._t("host_label")}: ${host}
` + : ""; + return `
${entry.title} ${this._t("setup_failed_badge")}
+ ${hostHtml}

${this._t("setup_failed_hint")}

- `).join(""); + `; + }).join(""); return `

⚠ ${this._t("setup_failed_section")}

@@ -156,6 +163,33 @@ class SshDockerPanel extends HTMLElement { `; } + // Returns a set of entity_ids (from hass.states) that belong to failed config entries. + // Used to exclude them from the regular container grid so they don't appear under + // "Unknown host" when a sensor entity still exists for a setup_error entry. + _getFailedEntityIds() { + const entityByEntryId = this._getEntityByEntryId(); + return new Set(Object.values(entityByEntryId).map((e) => e.entity_id)); + } + + // Returns a map from config entry_id → hass.states entity for failed entries. + _getEntityByEntryId() { + if (!this._hass || !this._failedEntries.length) return {}; + const failedEntryIds = new Set(this._failedEntries.map((e) => e.entry_id)); + const map = {}; + if (this._hass.entities) { + for (const [entityId, info] of Object.entries(this._hass.entities)) { + if ( + entityId.startsWith("sensor.ssh_docker_") && + failedEntryIds.has(info.config_entry_id) + ) { + const state = this._hass.states[entityId]; + if (state) map[info.config_entry_id] = state; + } + } + } + return map; + } + _t(key) { return (this._hass && this._hass.localize(`component.ssh_docker.entity.ui.${key}.name`)) || key; } @@ -170,8 +204,10 @@ class SshDockerPanel extends HTMLElement { _getAllContainers() { if (!this._hass) return []; + const failedEntityIds = this._getFailedEntityIds(); const containers = Object.values(this._hass.states).filter((entity) => - entity.entity_id.startsWith("sensor.ssh_docker_") + entity.entity_id.startsWith("sensor.ssh_docker_") && + !failedEntityIds.has(entity.entity_id) ); // Always sort alphabetically by display name. return containers.sort((a, b) => { @@ -579,6 +615,11 @@ class SshDockerPanel extends HTMLElement { color: var(--secondary-text-color, #727272); margin: 4px 0 8px; } + .setup-failed-host { + font-size: 0.85em; + color: var(--secondary-text-color, #727272); + margin: 6px 0 0; + } .open-settings-btn { background: #c0392b; }
From 29ea77d96af461857f5cab6fb4358980d7ec24a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:52:55 +0000 Subject: [PATCH 4/5] fix: exclude failed entries from regular grid using dual-strategy entity lookup Replace single hass.entities lookup with a two-strategy _findEntityForEntry() helper that first tries the entity registry (hass.entities / config_entry_id) and falls back to matching sensor entities by attributes.name === entry.title. This ensures that sensor entities belonging to setup_error config entries are always excluded from _getAllContainers() and never shown under "Unknown host", even in panel contexts where hass.entities is unavailable or doesn't expose config_entry_id. Agent-Logs-Url: https://github.com/gensyn/ssh_docker/sessions/e7337e42-8c79-4008-ab9f-34af78cb29e3 Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- frontend/ssh-docker-panel.js | 59 +++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/frontend/ssh-docker-panel.js b/frontend/ssh-docker-panel.js index cbf4333..fa4facc 100644 --- a/frontend/ssh-docker-panel.js +++ b/frontend/ssh-docker-panel.js @@ -122,17 +122,13 @@ class SshDockerPanel extends HTMLElement { } // Returns the HTML for the "failed entries" section shown above the container grid. - // For each failed entry, host info is sourced from a still-registered sensor entity - // (via hass.entities entity-registry lookup) when available. _renderFailedSection() { if (!this._failedEntries || this._failedEntries.length === 0) return ""; - // Build a map from config entry_id → sensor entity state (if it exists) - const entityByEntryId = this._getEntityByEntryId(); - const cards = this._failedEntries.map((entry) => { - const sensorEntity = entityByEntryId[entry.entry_id]; - const host = (sensorEntity && sensorEntity.attributes && sensorEntity.attributes.host) || ""; + // Prefer host from the API response options; fall back to matched entity attributes. + const host = (entry.options && entry.options.host) || + this._getHostForEntry(entry) || ""; const hostHtml = host ? `
${this._t("host_label")}: ${host}
` : ""; @@ -163,31 +159,60 @@ class SshDockerPanel extends HTMLElement { `; } + // Returns the host string for a failed entry by looking up the corresponding + // sensor entity in hass.states via entity name attribute matching. + _getHostForEntry(entry) { + if (!this._hass) return ""; + const entity = this._findEntityForEntry(entry); + return (entity && entity.attributes && entity.attributes.host) || ""; + } + // Returns a set of entity_ids (from hass.states) that belong to failed config entries. // Used to exclude them from the regular container grid so they don't appear under // "Unknown host" when a sensor entity still exists for a setup_error entry. _getFailedEntityIds() { - const entityByEntryId = this._getEntityByEntryId(); - return new Set(Object.values(entityByEntryId).map((e) => e.entity_id)); + if (!this._hass || !this._failedEntries.length) return new Set(); + const result = new Set(); + for (const entry of this._failedEntries) { + const entity = this._findEntityForEntry(entry); + if (entity) result.add(entity.entity_id); + } + return result; } - // Returns a map from config entry_id → hass.states entity for failed entries. - _getEntityByEntryId() { - if (!this._hass || !this._failedEntries.length) return {}; - const failedEntryIds = new Set(this._failedEntries.map((e) => e.entry_id)); - const map = {}; + // Finds the hass.states sensor entity corresponding to a failed config entry. + // Uses two strategies in order: + // 1. hass.entities registry (config_entry_id match) – most reliable when available. + // 2. Name-attribute match (attributes.name === entry.title) – fallback for panels + // where hass.entities is unavailable or doesn't expose config_entry_id. + _findEntityForEntry(entry) { + if (!this._hass) return null; + + // Strategy 1: entity registry (hass.entities) if (this._hass.entities) { for (const [entityId, info] of Object.entries(this._hass.entities)) { if ( entityId.startsWith("sensor.ssh_docker_") && - failedEntryIds.has(info.config_entry_id) + info.config_entry_id === entry.entry_id ) { const state = this._hass.states[entityId]; - if (state) map[info.config_entry_id] = state; + if (state) return state; } } } - return map; + + // Strategy 2: match sensor entity by name attribute + for (const state of Object.values(this._hass.states)) { + if ( + state.entity_id.startsWith("sensor.ssh_docker_") && + state.attributes && + state.attributes.name === entry.title + ) { + return state; + } + } + + return null; } _t(key) { From 890f9c3ab95fedb3081eee7f3a2af8d078b1ff8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:21:01 +0000 Subject: [PATCH 5/5] fix: rewrite _getFailedEntityIds to reliably exclude unavailable entities from grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the previous entity-matching chain caused failed entries to keep leaking into the regular container grid: 1. Strategy 1 required hass.states[entityId] to be truthy — so if the entity was in hass.entities but had no restored state, its entity_id was silently dropped from the exclusion set. 2. Strategy 2 matched on state.attributes.name, which is empty ({}) for restored-but-never-loaded unavailable entities, so the match never fired. 3. Both strategies required a state object, making them useless in the exact scenario that causes the bug (unavailable entity, empty attributes). New _getFailedEntityIds(): Strategy 1: entity registry (hass.entities) — adds entity_ids directly by config_entry_id with no hass.states lookup required; works even when the entity has never been loaded. Strategy 2: name-attribute match (state.attributes.name === entry.title) — fallback for entities with populated attributes when hass.entities is absent. Strategy 3: derived entity_id (sensor.ssh_docker_ + _slugify(title)) — covers the hardest case: no hass.entities AND empty attributes (restored unavailable). Strategies 2 and 3 are combined into a single hass.states pass to avoid redundant iteration. _slugify() mirrors HA Python's slugify exactly. Also removes the unused _findEntityForEntry() and _getHostForEntry() helpers. Agent-Logs-Url: https://github.com/gensyn/ssh_docker/sessions/5db279fb-d00e-410e-b802-136c4544053d Co-authored-by: gensyn <36128035+gensyn@users.noreply.github.com> --- frontend/ssh-docker-panel.js | 84 ++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/frontend/ssh-docker-panel.js b/frontend/ssh-docker-panel.js index fa4facc..5530d8b 100644 --- a/frontend/ssh-docker-panel.js +++ b/frontend/ssh-docker-panel.js @@ -126,9 +126,8 @@ class SshDockerPanel extends HTMLElement { if (!this._failedEntries || this._failedEntries.length === 0) return ""; const cards = this._failedEntries.map((entry) => { - // Prefer host from the API response options; fall back to matched entity attributes. - const host = (entry.options && entry.options.host) || - this._getHostForEntry(entry) || ""; + // Host is sourced from entry.options returned by the config entries API. + const host = (entry.options && entry.options.host) || ""; const hostHtml = host ? `
${this._t("host_label")}: ${host}
` : ""; @@ -159,60 +158,59 @@ class SshDockerPanel extends HTMLElement { `; } - // Returns the host string for a failed entry by looking up the corresponding - // sensor entity in hass.states via entity name attribute matching. - _getHostForEntry(entry) { - if (!this._hass) return ""; - const entity = this._findEntityForEntry(entry); - return (entity && entity.attributes && entity.attributes.host) || ""; - } - - // Returns a set of entity_ids (from hass.states) that belong to failed config entries. - // Used to exclude them from the regular container grid so they don't appear under - // "Unknown host" when a sensor entity still exists for a setup_error entry. + // Returns a set of entity_ids that belong to failed config entries. + // Uses three strategies so that entities are always excluded from the regular grid, + // even when attributes are empty (restored unavailable entity) or hass.entities is + // not yet populated. _getFailedEntityIds() { if (!this._hass || !this._failedEntries.length) return new Set(); + const failedEntryIds = new Set(this._failedEntries.map((e) => e.entry_id).filter(Boolean)); const result = new Set(); - for (const entry of this._failedEntries) { - const entity = this._findEntityForEntry(entry); - if (entity) result.add(entity.entity_id); - } - return result; - } - - // Finds the hass.states sensor entity corresponding to a failed config entry. - // Uses two strategies in order: - // 1. hass.entities registry (config_entry_id match) – most reliable when available. - // 2. Name-attribute match (attributes.name === entry.title) – fallback for panels - // where hass.entities is unavailable or doesn't expose config_entry_id. - _findEntityForEntry(entry) { - if (!this._hass) return null; - // Strategy 1: entity registry (hass.entities) + // Strategy 1: entity registry — add entity_ids directly by config_entry_id. + // Does NOT require the entity to have a state in hass.states, so it works even + // for restored-but-never-loaded entities that have no attributes. if (this._hass.entities) { for (const [entityId, info] of Object.entries(this._hass.entities)) { - if ( - entityId.startsWith("sensor.ssh_docker_") && - info.config_entry_id === entry.entry_id - ) { - const state = this._hass.states[entityId]; - if (state) return state; + if (entityId.startsWith("sensor.ssh_docker_") && failedEntryIds.has(info.config_entry_id)) { + result.add(entityId); } } } - // Strategy 2: match sensor entity by name attribute + // Strategies 2 & 3: iterate hass.states once as fallback when hass.entities is + // not available or the entity has no state yet. + const derivedIds = new Set( + this._failedEntries + .map((e) => e.title) + .filter(Boolean) + .map((t) => "sensor.ssh_docker_" + this._slugify(t)) + ); for (const state of Object.values(this._hass.states)) { - if ( - state.entity_id.startsWith("sensor.ssh_docker_") && - state.attributes && - state.attributes.name === entry.title - ) { - return state; + if (!state.entity_id.startsWith("sensor.ssh_docker_")) continue; + // Strategy 2: name-attribute match (works when entity has populated attributes). + if (state.attributes && state.attributes.name) { + for (const entry of this._failedEntries) { + if (entry.title && state.attributes.name === entry.title) { + result.add(state.entity_id); + break; + } + } } + // Strategy 3: derived entity_id match (covers unavailable entities with empty + // attributes, where the entity_id was generated from the entry title by HA's slugify). + if (derivedIds.has(state.entity_id)) result.add(state.entity_id); } - return null; + return result; + } + + // Mirrors HA's Python slugify: lowercases the text, replaces every run of + // non-alphanumeric characters with a single underscore, and strips leading/trailing + // underscores. Non-ASCII characters are treated as non-alphanumeric (become "_"). + // An all-non-alphanumeric input (e.g. "!!!") produces an empty string. + _slugify(text) { + return String(text).toLowerCase().replace(/[^a-z0-9]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, ""); } _t(key) {