Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions frontend/ssh-docker-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

_setFilter(filter) {
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -657,6 +703,9 @@ class SshDockerPanel extends HTMLElement {
${this._renderFailedSection()}
<div class="filters">${filterButtons}</div>
${hostFilterHtml}
<div class="name-filter-row">
<input class="name-filter-input" type="search" placeholder="${this._t("search_placeholder")}" value="${this._escapeHtml(this._nameFilter)}" aria-label="${this._t("search_placeholder")}">
</div>
${hostsHtml}
</div>
`;
Expand Down Expand Up @@ -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", () =>
Expand Down
3 changes: 2 additions & 1 deletion strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
102 changes: 102 additions & 0 deletions tests/playwright/test_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
3 changes: 2 additions & 1 deletion translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading