From 36e4bef46daf49a11d359ae2f9733aea444090c4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 21 Dec 2025 01:23:54 -0800 Subject: [PATCH] feat(web): add shell command to command palette for services (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Shell: {service}" commands to the command palette when on a stack page - Allows quick shell access to containers via `Cmd+K` → type "shell" → select service - Add `get_container_name()` helper in `compose.py` for consistent container name resolution (used by both api.py and pages.py) --- src/compose_farm/compose.py | 15 ++++++ src/compose_farm/web/routes/api.py | 8 +-- src/compose_farm/web/routes/pages.py | 16 +++++- src/compose_farm/web/static/app.js | 14 +++++- src/compose_farm/web/templates/stack.html | 2 +- tests/test_compose.py | 60 +++++++++++++++++++++++ tests/web/test_htmx_browser.py | 41 ++++++++++++++++ tests/web/test_template_context.py | 9 ++++ 8 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 tests/test_compose.py diff --git a/src/compose_farm/compose.py b/src/compose_farm/compose.py index 02e69ee..bea2648 100644 --- a/src/compose_farm/compose.py +++ b/src/compose_farm/compose.py @@ -336,3 +336,18 @@ def get_ports_for_service( if isinstance(ref_def, dict): return _parse_ports(ref_def.get("ports"), env) return _parse_ports(definition.get("ports"), env) + + +def get_container_name( + service_name: str, + service_def: dict[str, Any] | None, + project_name: str, +) -> str: + """Get the container name for a service. + + Uses container_name from compose if set, otherwise defaults to {project}-{service}-1. + This matches Docker Compose's default naming convention. + """ + if isinstance(service_def, dict) and service_def.get("container_name"): + return str(service_def["container_name"]) + return f"{project_name}-{service_name}-1" diff --git a/src/compose_farm/web/routes/api.py b/src/compose_farm/web/routes/api.py index 67a35ac..94cfa62 100644 --- a/src/compose_farm/web/routes/api.py +++ b/src/compose_farm/web/routes/api.py @@ -19,6 +19,7 @@ import yaml from fastapi import APIRouter, Body, HTTPException, Query from fastapi.responses import HTMLResponse +from compose_farm.compose import get_container_name from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs from compose_farm.paths import find_config_path from compose_farm.state import load_state @@ -116,14 +117,9 @@ def _get_compose_services(config: Any, stack: str, hosts: list[str]) -> list[dic containers = [] for host in hosts: for svc_name, svc_def in raw_services.items(): - # Use container_name if set, otherwise default to {project}-{service}-1 - if isinstance(svc_def, dict) and svc_def.get("container_name"): - container_name = svc_def["container_name"] - else: - container_name = f"{project_name}-{svc_name}-1" containers.append( { - "Name": container_name, + "Name": get_container_name(svc_name, svc_def, project_name), "Service": svc_name, "Host": host, "State": "unknown", # Status requires Docker query diff --git a/src/compose_farm/web/routes/pages.py b/src/compose_farm/web/routes/pages.py index e831f5f..96a6ea2 100644 --- a/src/compose_farm/web/routes/pages.py +++ b/src/compose_farm/web/routes/pages.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse from pydantic import ValidationError +from compose_farm.compose import get_container_name from compose_farm.paths import find_config_path from compose_farm.state import ( get_orphaned_stacks, @@ -159,13 +160,25 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse: # Get state current_host = get_stack_host(config, name) - # Get service names from compose file + # Get service names and container info from compose file services: list[str] = [] + containers: dict[str, dict[str, str]] = {} + shell_host = current_host[0] if isinstance(current_host, list) else current_host if compose_content: compose_data = yaml.safe_load(compose_content) or {} raw_services = compose_data.get("services", {}) if isinstance(raw_services, dict): services = list(raw_services.keys()) + # Build container info for shell access (only if stack is running) + if shell_host: + project_name = compose_path.parent.name if compose_path else name + containers = { + svc: { + "container": get_container_name(svc, svc_def, project_name), + "host": shell_host, + } + for svc, svc_def in raw_services.items() + } return templates.TemplateResponse( "stack.html", @@ -179,6 +192,7 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse: "env_content": env_content, "env_path": str(env_path) if env_path else None, "services": services, + "containers": containers, }, ) diff --git a/src/compose_farm/web/static/app.js b/src/compose_farm/web/static/app.js index ad27856..ba065de 100644 --- a/src/compose_farm/web/static/app.js +++ b/src/compose_farm/web/static/app.js @@ -590,11 +590,15 @@ function playFabIntro() { stackCmd('Logs', 'View logs for', 'logs', icons.file_text), ); - // Add service-specific commands from data-services attribute + // Add service-specific commands from data-services and data-containers attributes // Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services'); + const containersAttr = document.querySelector('[data-containers]')?.getAttribute('data-containers'); if (servicesAttr) { const services = servicesAttr.split(',').filter(s => s).sort(); + // Parse container info for shell access: {service: {container, host}} + const containers = containersAttr ? JSON.parse(containersAttr) : {}; + const svcCmd = (action, service, desc, endpoint, icon) => cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon); const svcActions = [ @@ -609,6 +613,14 @@ function playFabIntro() { actions.push(svcCmd(action, service, desc, endpoint, icon)); } } + // Add Shell commands if container info is available + for (const service of services) { + const info = containers[service]; + if (info?.container && info?.host) { + actions.push(cmd('service', `Shell: ${service}`, 'Open interactive shell', + () => initExecTerminal(stack, info.container, info.host), icons.terminal)); + } + } } } diff --git a/src/compose_farm/web/templates/stack.html b/src/compose_farm/web/templates/stack.html index 91256b4..8bcd552 100644 --- a/src/compose_farm/web/templates/stack.html +++ b/src/compose_farm/web/templates/stack.html @@ -4,7 +4,7 @@ {% block title %}{{ name }} - Compose Farm{% endblock %} {% block content %} -
+

{{ name }}

diff --git a/tests/test_compose.py b/tests/test_compose.py new file mode 100644 index 0000000..1742aaa --- /dev/null +++ b/tests/test_compose.py @@ -0,0 +1,60 @@ +"""Tests for compose file parsing utilities.""" + +from __future__ import annotations + +import pytest + +from compose_farm.compose import get_container_name + + +class TestGetContainerName: + """Test get_container_name helper function.""" + + def test_explicit_container_name(self) -> None: + """Uses container_name from service definition when set.""" + service_def = {"image": "nginx", "container_name": "my-custom-name"} + result = get_container_name("web", service_def, "myproject") + assert result == "my-custom-name" + + def test_default_naming_pattern(self) -> None: + """Falls back to {project}-{service}-1 pattern.""" + service_def = {"image": "nginx"} + result = get_container_name("web", service_def, "myproject") + assert result == "myproject-web-1" + + def test_none_service_def(self) -> None: + """Handles None service definition gracefully.""" + result = get_container_name("web", None, "myproject") + assert result == "myproject-web-1" + + def test_empty_service_def(self) -> None: + """Handles empty service definition.""" + result = get_container_name("web", {}, "myproject") + assert result == "myproject-web-1" + + def test_container_name_none_value(self) -> None: + """Handles container_name set to None.""" + service_def = {"image": "nginx", "container_name": None} + result = get_container_name("web", service_def, "myproject") + assert result == "myproject-web-1" + + def test_container_name_empty_string(self) -> None: + """Handles container_name set to empty string.""" + service_def = {"image": "nginx", "container_name": ""} + result = get_container_name("web", service_def, "myproject") + assert result == "myproject-web-1" + + @pytest.mark.parametrize( + ("service_name", "project_name", "expected"), + [ + ("redis", "plex", "plex-redis-1"), + ("plex-server", "media", "media-plex-server-1"), + ("db", "my-app", "my-app-db-1"), + ], + ) + def test_various_naming_combinations( + self, service_name: str, project_name: str, expected: str + ) -> None: + """Test various service/project name combinations.""" + result = get_container_name(service_name, {"image": "test"}, project_name) + assert result == expected diff --git a/tests/web/test_htmx_browser.py b/tests/web/test_htmx_browser.py index d692f4e..817bf77 100644 --- a/tests/web/test_htmx_browser.py +++ b/tests/web/test_htmx_browser.py @@ -1875,6 +1875,47 @@ class TestServicePagePalette: cmd_list = page.locator("#cmd-list").inner_text() assert "Restart: plex-server" in cmd_list + def test_shell_commands_in_palette(self, page: Page, server_url: str) -> None: + """Command palette includes Shell commands for each service.""" + page.goto(server_url) + page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT) + + # Navigate to plex stack + page.locator("#sidebar-stacks a", has_text="plex").click() + page.wait_for_url("**/stack/plex", timeout=TIMEOUT) + + # Open command palette + page.keyboard.press("Control+k") + page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT) + + # Filter to shell commands + page.locator("#cmd-input").fill("Shell:") + cmd_list = page.locator("#cmd-list").inner_text() + + # Should have Shell commands for plex-server and redis services + assert "Shell: plex-server" in cmd_list + assert "Shell: redis" in cmd_list + + def test_shell_command_fuzzy_match(self, page: Page, server_url: str) -> None: + """Shell commands can be found with fuzzy search.""" + page.goto(server_url) + page.wait_for_selector("#sidebar-stacks a", timeout=TIMEOUT) + + # Navigate to plex stack + page.locator("#sidebar-stacks a", has_text="plex").click() + page.wait_for_url("**/stack/plex", timeout=TIMEOUT) + + # Open command palette + page.keyboard.press("Control+k") + page.wait_for_selector("#cmd-palette[open]", timeout=SHORT_TIMEOUT) + + # Type "shell redis" without colon + page.locator("#cmd-input").fill("shell redis") + cmd_list = page.locator("#cmd-list").inner_text() + + # Should match "Shell: redis" + assert "Shell: redis" in cmd_list + class TestThemeSwitcher: """Test theme switcher via command palette.""" diff --git a/tests/web/test_template_context.py b/tests/web/test_template_context.py index 688cacc..d894f4e 100644 --- a/tests/web/test_template_context.py +++ b/tests/web/test_template_context.py @@ -84,6 +84,15 @@ class TestPageTemplatesRender: assert response.status_code == 200 assert "test-service" in response.text + def test_stack_detail_has_containers_data(self, client: TestClient) -> None: + """Test stack detail page includes data-containers for command palette shell.""" + response = client.get("/stack/test-service") + assert response.status_code == 200 + # Should have data-containers attribute with JSON + assert "data-containers=" in response.text + # Container name should follow {project}-{service}-1 pattern + assert "test-service-app-1" in response.text + class TestPartialTemplatesRender: """Test that partial templates render without missing variables."""