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": {