Compare commits

...

7 Commits

Author SHA1 Message Date
Bas Nijholt
ea650bff8a fix: Skip buildable images in pull command (#109)
* fix: Skip buildable images in pull command

Add --ignore-buildable flag to pull command, matching the behavior
of the update command. This prevents pull from failing when a stack
contains services with local build directives (no remote image).

* test: Fix flaky command palette close detection

Use state="hidden" instead of :not([open]) selector when waiting
for the command palette to close. The old approach failed because
wait_for_selector defaults to waiting for visibility, but a closed
<dialog> element is hidden by design.
2025-12-21 10:28:10 -08:00
renovate[bot]
140bca4fd6 ⬆️ Update actions/upload-pages-artifact action to v4 (#108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 10:27:58 -08:00
renovate[bot]
6dad6be8da ⬆️ Update actions/checkout action to v6 (#107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 10:27:51 -08:00
Bas Nijholt
d7f931e301 feat(web): improve container row layout for mobile (#106)
- Stack container name/status above buttons on mobile screens
- Use card-like background for visual separation
- Buttons align right on desktop, full width on mobile
2025-12-21 10:27:36 -08:00
Bas Nijholt
471936439e feat(web): add Edit Config command to command palette (#105)
- Added "Edit Config" command to the command palette (Cmd/Ctrl+K)
- Navigates to console page, focuses the Monaco editor, and scrolls to it
- Uses `#editor` URL hash to signal editor focus instead of terminal focus
2025-12-21 01:24:03 -08:00
Bas Nijholt
36e4bef46d feat(web): add shell command to command palette for services (#104)
- 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)
2025-12-21 01:23:54 -08:00
Bas Nijholt
2cac0bf263 feat(web): add Pull All and Update All to command palette (#103)
The dashboard buttons for Pull All and Update All are now also
available in the command palette (Cmd/Ctrl+K) for keyboard access.
2025-12-21 01:00:57 -08:00
13 changed files with 205 additions and 35 deletions

View File

@@ -27,7 +27,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
lfs: true
@@ -49,7 +49,7 @@ jobs:
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v4
with:
path: "./site"

View File

@@ -140,7 +140,7 @@ def pull(
if service and len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
cmd = f"pull {service}" if service else "pull"
cmd = f"pull --ignore-buildable {service}" if service else "pull --ignore-buildable"
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
report_results(results)

View File

@@ -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"

View File

@@ -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

View File

@@ -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,
},
)

View File

@@ -523,9 +523,15 @@ function playFabIntro() {
let originalTheme = null; // Store theme when palette opens for preview/restore
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => {
const nav = (url, afterNav) => () => {
// Set hash before HTMX swap so inline scripts can read it
const hashIndex = url.indexOf('#');
if (hashIndex !== -1) {
window.location.hash = url.substring(hashIndex);
}
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
history.pushState({}, '', url);
afterNav?.();
});
};
// Navigate to dashboard (if needed) and trigger action
@@ -568,9 +574,12 @@ function playFabIntro() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code),
cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open),
];
@@ -588,11 +597,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 = [
@@ -607,6 +620,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));
}
}
}
}

View File

@@ -97,7 +97,10 @@ function connectConsole() {
consoleWs.onopen = () => {
statusEl.textContent = `Connected to ${host}`;
sendSize(term.cols, term.rows);
term.focus();
// Focus terminal unless #editor hash is present (command palette Edit Config)
if (window.location.hash !== '#editor') {
term.focus();
}
// Auto-load the default file once editor is ready
const pathInput = document.getElementById('console-file-path');
if (pathInput && pathInput.value) {
@@ -133,6 +136,14 @@ function initConsoleEditor() {
loadMonaco(() => {
consoleEditor = createEditor(editorEl, '', 'plaintext', { onSave: saveFile });
// Focus editor if #editor hash is present (command palette Edit Config)
if (window.location.hash === '#editor') {
// Small delay for Monaco to fully initialize before focusing
setTimeout(() => {
consoleEditor.focus();
editorEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
});
}

View File

@@ -1,4 +1,4 @@
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, check, home, terminal, box, palette, book_open %}
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, file_code, check, home, terminal, box, palette, book_open %}
<!-- Icons for command palette (referenced by JS) -->
<template id="cmd-icons">
@@ -14,6 +14,7 @@
<span data-icon="box">{{ box() }}</span>
<span data-icon="palette">{{ palette() }}</span>
<span data-icon="book_open">{{ book_open() }}</span>
<span data-icon="file_code">{{ file_code() }}</span>
</template>
<dialog id="cmd-palette" class="modal">
<div class="modal-box max-w-lg p-0">

View File

@@ -1,24 +1,26 @@
{# Container list for a stack on a single host #}
{% from "partials/icons.html" import terminal, rotate_ccw, scroll_text, square, play, cloud_download %}
{% macro container_row(stack, container, host) %}
<div class="flex items-center gap-2 mb-2">
{% if container.State == "running" %}
<span class="badge badge-success">running</span>
{% elif container.State == "unknown" %}
<span class="badge badge-ghost"><span class="loading loading-spinner loading-xs"></span></span>
{% elif container.State == "exited" %}
{% if container.ExitCode == 0 %}
<span class="badge badge-neutral">exited (0)</span>
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-3 p-2 bg-base-200 rounded-lg">
<div class="flex items-center gap-2 min-w-0">
{% if container.State == "running" %}
<span class="badge badge-success">running</span>
{% elif container.State == "unknown" %}
<span class="badge badge-ghost"><span class="loading loading-spinner loading-xs"></span></span>
{% elif container.State == "exited" %}
{% if container.ExitCode == 0 %}
<span class="badge badge-neutral">exited (0)</span>
{% else %}
<span class="badge badge-error">exited ({{ container.ExitCode }})</span>
{% endif %}
{% elif container.State == "created" %}
<span class="badge badge-neutral">created</span>
{% else %}
<span class="badge badge-error">exited ({{ container.ExitCode }})</span>
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
{% elif container.State == "created" %}
<span class="badge badge-neutral">created</span>
{% else %}
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
<code class="text-sm flex-1">{{ container.Name }}</code>
<div class="join">
<code class="text-sm truncate">{{ container.Name }}</code>
</div>
<div class="join sm:ml-auto shrink-0">
<div class="tooltip tooltip-top" data-tip="View logs">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/logs"

View File

@@ -4,7 +4,7 @@
{% block title %}{{ name }} - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-5xl" data-services="{{ services | join(',') }}">
<div class="max-w-5xl" data-services="{{ services | join(',') }}" data-containers='{{ containers | tojson }}'>
<div class="mb-6">
<h1 class="text-3xl font-bold rainbow-hover">{{ name }}</h1>
<div class="flex flex-wrap items-center gap-2 mt-2">

60
tests/test_compose.py Normal file
View File

@@ -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

View File

@@ -669,8 +669,8 @@ class TestCommandPalette:
page.locator("#cmd-input").fill("plex")
page.keyboard.press("Enter")
# Palette should close
page.wait_for_selector("#cmd-palette:not([open])", timeout=SHORT_TIMEOUT)
# Palette should close (use state="hidden" since closed dialog is not visible)
page.wait_for_selector("#cmd-palette", state="hidden", timeout=SHORT_TIMEOUT)
# Should navigate to plex stack page
page.wait_for_url("**/stack/plex", timeout=TIMEOUT)
@@ -699,8 +699,8 @@ class TestCommandPalette:
page.keyboard.press("Escape")
# Palette should close, URL unchanged
page.wait_for_selector("#cmd-palette:not([open])", timeout=SHORT_TIMEOUT)
# Palette should close, URL unchanged (use state="hidden" since closed dialog is not visible)
page.wait_for_selector("#cmd-palette", state="hidden", timeout=SHORT_TIMEOUT)
assert page.url.rstrip("/") == server_url.rstrip("/")
def test_fab_button_opens_palette(self, page: Page, server_url: str) -> None:
@@ -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."""

View File

@@ -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."""