${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) {