diff --git a/frontend/ssh-docker-panel.js b/frontend/ssh-docker-panel.js
index ad2a18b..19696f1 100644
--- a/frontend/ssh-docker-panel.js
+++ b/frontend/ssh-docker-panel.js
@@ -6,6 +6,7 @@ class SshDockerPanel extends HTMLElement {
this.attachShadow({ mode: "open" });
this._filter = "all";
this._hostFilter = "all";
+ this._nameFilter = "";
this._narrow = false;
this._lastSnapshot = null;
this._collapsedHosts = new Set();
@@ -250,9 +251,27 @@ class SshDockerPanel extends HTMLElement {
}
_getFilteredContainers() {
- const containers = this._getStateFilteredContainers();
- if (this._hostFilter === "all") return containers;
- return containers.filter((c) => this._getContainerHost(c) === this._hostFilter);
+ let containers = this._getStateFilteredContainers();
+ if (this._hostFilter !== "all") {
+ containers = containers.filter((c) => this._getContainerHost(c) === this._hostFilter);
+ }
+ if (this._nameFilter) {
+ const needle = this._nameFilter.toLowerCase();
+ containers = containers.filter((c) => {
+ const name = ((c.attributes && c.attributes.name) || c.entity_id).toLowerCase();
+ return name.includes(needle);
+ });
+ }
+ return containers;
+ }
+
+ _escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
_setFilter(filter) {
@@ -365,6 +384,14 @@ class SshDockerPanel extends HTMLElement {
_render() {
if (!this._hass) return;
+ // Preserve name-filter input focus and cursor position across re-renders.
+ // shadowRoot.innerHTML replacement destroys the old element, so we must
+ // save and restore this state manually.
+ const prevInput = this.shadowRoot.querySelector(".name-filter-input");
+ const nameInputFocused = prevInput && this.shadowRoot.activeElement === prevInput;
+ const nameInputSelStart = nameInputFocused ? prevInput.selectionStart : null;
+ const nameInputSelEnd = nameInputFocused ? prevInput.selectionEnd : null;
+
const allContainers = this._getAllContainers();
const states = ["running", "exited", "paused", "restarting", "starting", "dead", "created", "removing", "stopping", "creating", "initializing", "pulling", "unavailable", "refreshing"];
@@ -482,6 +509,25 @@ class SshDockerPanel extends HTMLElement {
.host-filters {
margin-top: -8px;
}
+ .name-filter-row {
+ margin-bottom: 16px;
+ }
+ .name-filter-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 8px 12px;
+ border: 2px solid var(--divider-color, #e0e0e0);
+ border-radius: 20px;
+ background: var(--card-background-color, white);
+ color: var(--primary-text-color, #212121);
+ font-size: 0.9rem;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.2s;
+ }
+ .name-filter-input:focus {
+ border-color: var(--primary-color, #03a9f4);
+ }
.filter-btn {
padding: 6px 14px;
border: 2px solid var(--primary-color, #03a9f4);
@@ -657,6 +703,9 @@ class SshDockerPanel extends HTMLElement {
${this._renderFailedSection()}
${filterButtons}
${hostFilterHtml}
+
+
+
${hostsHtml}
`;
@@ -690,6 +739,20 @@ class SshDockerPanel extends HTMLElement {
btn.addEventListener("click", () => this._setHostFilter(btn.dataset.host));
});
+ const nameInput = this.shadowRoot.querySelector(".name-filter-input");
+ if (nameInput) {
+ nameInput.addEventListener("input", (e) => {
+ this._nameFilter = e.target.value;
+ this._render();
+ });
+ if (nameInputFocused) {
+ nameInput.focus();
+ if (nameInputSelStart !== null) {
+ nameInput.setSelectionRange(nameInputSelStart, nameInputSelEnd);
+ }
+ }
+ }
+
this.shadowRoot.querySelectorAll(".action-btn").forEach((btn) => {
if (btn.dataset.action === "logs") {
btn.addEventListener("click", () =>
diff --git a/strings.json b/strings.json
index e93c3dc..255f8d8 100644
--- a/strings.json
+++ b/strings.json
@@ -225,7 +225,8 @@
"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" }
+ "btn_open_settings": { "name": "Open settings" },
+ "search_placeholder": { "name": "Filter by name…" }
}
},
"exceptions": {
diff --git a/tests/playwright/test_frontend.py b/tests/playwright/test_frontend.py
index e6c7bb8..27dfe01 100644
--- a/tests/playwright/test_frontend.py
+++ b/tests/playwright/test_frontend.py
@@ -78,3 +78,105 @@ def test_developer_tools_shows_ssh_docker_services(
page.wait_for_load_state("networkidle")
# Page should be accessible and not redirect
assert page.url.startswith(HA_URL)
+
+ def test_panel_name_filter_input_present(
+ self, page: Page, ensure_integration: str
+ ) -> None:
+ """The SSH Docker panel contains a name-filter text input."""
+ page.goto(f"{HA_URL}/ssh-docker")
+ page.wait_for_load_state("networkidle")
+
+ # The panel uses shadow DOM; reach the input via JavaScript.
+ input_present = page.evaluate(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ if (!panel || !panel.shadowRoot) return false;
+ return !!panel.shadowRoot.querySelector(".name-filter-input");
+ }"""
+ )
+ assert input_present, "Name filter input not found in ssh-docker-panel shadow DOM"
+
+ def test_panel_name_filter_hides_non_matching_containers(
+ self, page: Page, ensure_integration: str
+ ) -> None:
+ """Typing a non-matching name into the filter removes containers from the panel."""
+ page.goto(f"{HA_URL}/ssh-docker")
+ page.wait_for_load_state("networkidle")
+
+ # Wait until at least one container card is rendered.
+ page.wait_for_function(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ if (!panel || !panel.shadowRoot) return false;
+ return panel.shadowRoot.querySelectorAll(".container-card").length > 0;
+ }""",
+ timeout=15000,
+ )
+
+ # Type a search string that will not match any container name.
+ page.evaluate(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ const input = panel.shadowRoot.querySelector(".name-filter-input");
+ input.value = "__no_match_xyzzy__";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }"""
+ )
+
+ # After filtering, no container cards should remain.
+ page.wait_for_function(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ if (!panel || !panel.shadowRoot) return false;
+ return panel.shadowRoot.querySelectorAll(".container-card").length === 0;
+ }""",
+ timeout=5000,
+ )
+
+ def test_panel_name_filter_case_insensitive(
+ self, page: Page, ensure_integration: str
+ ) -> None:
+ """The name filter matches container names case-insensitively."""
+ page.goto(f"{HA_URL}/ssh-docker")
+ page.wait_for_load_state("networkidle")
+
+ # Wait until at least one container card is rendered.
+ page.wait_for_function(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ if (!panel || !panel.shadowRoot) return false;
+ return panel.shadowRoot.querySelectorAll(".container-card").length > 0;
+ }""",
+ timeout=15000,
+ )
+
+ # Retrieve the name of the first container card (case will vary).
+ first_name: str = page.evaluate(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ const el = panel.shadowRoot.querySelector(".container-name");
+ return el ? el.textContent.trim() : "";
+ }"""
+ )
+ assert first_name, "Could not read any container name from the panel"
+
+ # Search using swapped case to verify case-insensitive matching.
+ search_term = first_name.swapcase()
+ page.evaluate(
+ f"""() => {{
+ const panel = document.querySelector("ssh-docker-panel");
+ const input = panel.shadowRoot.querySelector(".name-filter-input");
+ input.value = {repr(search_term)};
+ input.dispatchEvent(new Event("input", {{ bubbles: true }}));
+ }}"""
+ )
+
+ # The original container should still be visible.
+ page.wait_for_function(
+ """() => {
+ const panel = document.querySelector("ssh-docker-panel");
+ if (!panel || !panel.shadowRoot) return false;
+ return panel.shadowRoot.querySelectorAll(".container-card").length > 0;
+ }""",
+ timeout=5000,
+ )
diff --git a/translations/de.json b/translations/de.json
index 802754e..a649522 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -225,7 +225,8 @@
"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" }
+ "btn_open_settings": { "name": "Einstellungen öffnen" },
+ "search_placeholder": { "name": "Nach Name filtern…" }
}
},
"exceptions": {
diff --git a/translations/en.json b/translations/en.json
index e93c3dc..255f8d8 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -225,7 +225,8 @@
"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" }
+ "btn_open_settings": { "name": "Open settings" },
+ "search_placeholder": { "name": "Filter by name…" }
}
},
"exceptions": {