Compare commits

...

7 Commits

Author SHA1 Message Date
Bas Nijholt
7caf006e07 feat(web): add Rich logging for better error debugging (#90)
Add structured logging with Rich tracebacks to web UI components:
- Configure RichHandler in app.py for formatted output
- Log SSH/file operation failures in API routes with full tracebacks
- Log WebSocket exec/shell errors for connection issues
- Add warning logs for failed container state queries

Errors now show detailed tracebacks in container logs instead of
just returning 500 status codes.
2025-12-20 20:47:34 -08:00
Bas Nijholt
45040b75f1 feat(web): add Pull All and Update All buttons to dashboard (#89)
- Add "Pull All" and "Update All" buttons to dashboard for bulk operations
- Switch from native `title` attribute to DaisyUI tooltips for instant, styled tooltips
- Add tooltips to save buttons clarifying what they save
- Add tooltip to container shell button
- Fix tooltip z-index so they appear above sidebar
- Fix tooltip clipping by removing `overflow-y-auto` from main content
- Position container shell tooltip to the left to avoid clipping
2025-12-20 20:41:26 -08:00
Bas Nijholt
fa1c5c1044 docs: update theme to indigo with system preference support (#88)
Switch from teal to indigo primary color to match Zensical docs theme.
Add system preference detection and orange accent for dark mode.
2025-12-20 20:18:28 -08:00
Bas Nijholt
67e832f687 docs: clarify config file locations and update install URL (#86) 2025-12-20 20:12:06 -08:00
Bas Nijholt
da986fab6a fix: improve command palette theme filtering (#87)
- Normalize spaces after colons so "theme:dark" matches "theme: dark"
- Also handles multiple spaces like "theme:  dark"
2025-12-20 20:03:16 -08:00
Bas Nijholt
5dd6e2ca05 fix: improve theme picker usability in command palette (#85) 2025-12-20 20:00:05 -08:00
Bas Nijholt
16435065de fix: video autoplay for Safari and Chrome with instant navigation (#84) 2025-12-20 19:49:05 -08:00
21 changed files with 145 additions and 37 deletions

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ compose-farm.yaml
coverage.xml
.env
homepage/
site/

View File

@@ -155,7 +155,7 @@ If you need containers on different hosts to communicate seamlessly, you need Do
```bash
# One-liner (installs uv if needed)
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
curl -fsSL https://compose-farm.nijho.lt/install | sh
# Or if you already have uv/pip
uv tool install compose-farm
@@ -237,7 +237,7 @@ The keys will persist across restarts.
## Configuration
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`). This keeps config near your stacks. Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config, or symlink from one to the other with `cf config symlink`.
### Single-host example

View File

@@ -43,7 +43,7 @@ cf --help, -h # Show help
Make reality match your configuration. The primary reconciliation command.
<video autoplay loop muted playsinline>
<source src="/assets/apply.webm#t=0.001" type="video/webm">
<source src="/assets/apply.webm" type="video/webm">
</video>
```bash
@@ -187,7 +187,7 @@ cf restart --all
Update stacks (pull + build + down + up).
<video autoplay loop muted playsinline>
<source src="/assets/update.webm#t=0.001" type="video/webm">
<source src="/assets/update.webm" type="video/webm">
</video>
```bash
@@ -275,7 +275,7 @@ cf ps --host nuc
Show stack logs.
<video autoplay loop muted playsinline>
<source src="/assets/logs.webm#t=0.001" type="video/webm">
<source src="/assets/logs.webm" type="video/webm">
</video>
```bash

View File

@@ -63,7 +63,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
pause(page, 400)
# Type to filter to a light theme (theme button pre-populates "theme:")
slow_type(page, "#cmd-input", " cup", delay=100)
slow_type(page, "#cmd-input", "cup", delay=100)
pause(page, 500)
page.keyboard.press("Enter")
pause(page, 1000)
@@ -75,7 +75,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 300)
slow_type(page, "#cmd-input", " dark", delay=100)
slow_type(page, "#cmd-input", "dark", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 800)

View File

@@ -18,13 +18,13 @@ Before you begin, ensure you have:
## Installation
<video autoplay loop muted playsinline>
<source src="/assets/install.webm#t=0.001" type="video/webm">
<source src="/assets/install.webm" type="video/webm">
</video>
### One-liner (recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
curl -fsSL https://compose-farm.nijho.lt/install | sh
```
This installs [uv](https://docs.astral.sh/uv/) if needed, then installs compose-farm.
@@ -123,7 +123,21 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0
### Create Config File
Create `~/.config/compose-farm/compose-farm.yaml`:
Create `compose-farm.yaml` in the directory where you'll run commands. For example, if your stacks are in `/opt/stacks`, place the config there too:
```bash
cd /opt/stacks
cf config init
```
Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config. You can also symlink a working directory config to the global location:
```bash
# Create config in your stacks directory, symlink to ~/.config
cf config symlink /opt/stacks/compose-farm.yaml
```
This way, `cf` commands work from anywhere while the config lives with your stacks.
#### Single host example

View File

@@ -17,12 +17,12 @@ It also works great on a single host with one folder per stack; just map stacks
**CLI:**
<video autoplay loop muted playsinline>
<source src="/assets/quickstart.webm#t=0.001" type="video/webm">
<source src="/assets/quickstart.webm" type="video/webm">
</video>
**[Web UI](web-ui.md):**
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm#t=0.001" type="video/webm">
<source src="/assets/web-workflow.webm" type="video/webm">
</video>
## Why Compose Farm?
@@ -96,7 +96,7 @@ pip install compose-farm
### Configuration
Create `~/.config/compose-farm/compose-farm.yaml`:
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`), or in `~/.config/compose-farm/`:
```yaml
compose_dir: /opt/compose
@@ -114,6 +114,8 @@ stacks:
radarr: hp
```
See [Configuration](configuration.md) for all options and the full search order.
### Usage
```bash
@@ -136,7 +138,7 @@ cf logs -f plex
- **Auto-migration**: Change a host assignment, run `cf up`, stack moves automatically
<video autoplay loop muted playsinline>
<source src="/assets/migration.webm#t=0.001" type="video/webm">
<source src="/assets/migration.webm" type="video/webm">
</video>
- **Parallel execution**: Multiple stacks start/stop concurrently
- **State tracking**: Knows which stacks are running where

2
bootstrap.sh → docs/install Executable file → Normal file
View File

@@ -1,6 +1,6 @@
#!/bin/sh
# Compose Farm bootstrap script
# Usage: curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
# Usage: curl -fsSL https://compose-farm.nijho.lt/install | sh
#
# This script installs uv (if needed) and then installs compose-farm as a uv tool.

View File

@@ -0,0 +1,21 @@
// Fix Safari video autoplay issues
(function() {
function initVideos() {
document.querySelectorAll('video[autoplay]').forEach(function(video) {
video.load();
video.play().catch(function() {});
});
}
// For initial page load (needed for Chrome)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initVideos);
} else {
initVideos();
}
// For MkDocs instant navigation (needed for Safari)
if (typeof document$ !== 'undefined') {
document$.subscribe(initVideos);
}
})();

View File

@@ -19,7 +19,7 @@ Then open [http://localhost:8000](http://localhost:8000).
Console terminal, config editor, stack navigation, actions (up, logs, update), dashboard overview, and theme switching - all in one flow.
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm#t=0.001" type="video/webm">
<source src="/assets/web-workflow.webm" type="video/webm">
</video>
### Stack Actions
@@ -27,7 +27,7 @@ Console terminal, config editor, stack navigation, actions (up, logs, update), d
Navigate to any stack and use the command palette to trigger actions like restart, pull, update, or view logs. Output streams in real-time via WebSocket.
<video autoplay loop muted playsinline>
<source src="/assets/web-stack.webm#t=0.001" type="video/webm">
<source src="/assets/web-stack.webm" type="video/webm">
</video>
### Theme Switching
@@ -35,7 +35,7 @@ Navigate to any stack and use the command palette to trigger actions like restar
35 themes available via the command palette. Type `theme:` to filter, then use arrow keys to preview themes live before selecting.
<video autoplay loop muted playsinline>
<source src="/assets/web-themes.webm#t=0.001" type="video/webm">
<source src="/assets/web-themes.webm" type="video/webm">
</video>
### Command Palette
@@ -43,7 +43,7 @@ Navigate to any stack and use the command palette to trigger actions like restar
Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes.
<video autoplay loop muted playsinline>
<source src="/assets/web-navigation.webm#t=0.001" type="video/webm">
<source src="/assets/web-navigation.webm" type="video/webm">
</video>
## Pages
@@ -70,7 +70,7 @@ Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy sear
- Monaco editor with syntax highlighting
<video autoplay loop muted playsinline>
<source src="/assets/web-console.webm#t=0.001" type="video/webm">
<source src="/assets/web-console.webm" type="video/webm">
</video>
### Container Shell
@@ -78,7 +78,7 @@ Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy sear
Click the Shell button on any running container to exec into it directly from the browser.
<video autoplay loop muted playsinline>
<source src="/assets/web-shell.webm#t=0.001" type="video/webm">
<source src="/assets/web-shell.webm" type="video/webm">
</video>
## Keyboard Shortcuts

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
@@ -10,11 +11,22 @@ from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from rich.logging import RichHandler
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
# Configure logging with Rich handler for compose_farm.web modules
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
# Set our web modules to INFO level (uvicorn handles its own logging)
logging.getLogger("compose_farm.web").setLevel(logging.INFO)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator

View File

@@ -64,3 +64,19 @@ async def refresh_state() -> dict[str, Any]:
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
return {"task_id": task_id, "command": "refresh"}
@router.post("/pull-all")
async def pull_all() -> dict[str, Any]:
"""Pull latest images for all stacks."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["pull", "--all"], tid))
return {"task_id": task_id, "command": "pull --all"}
@router.post("/update-all")
async def update_all() -> dict[str, Any]:
"""Update all stacks (pull + build + down + up)."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
return {"task_id": task_id, "command": "update --all"}

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import shlex
from datetime import UTC, datetime
from pathlib import Path
@@ -23,6 +24,8 @@ from compose_farm.paths import find_config_path
from compose_farm.state import load_state
from compose_farm.web.deps import get_config, get_templates
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"])
@@ -144,6 +147,12 @@ async def _get_container_states(
config, stack, host_name, "ps -a --format json", stream=False
)
if not result.success:
logger.warning(
"Failed to get container states for %s on %s: %s",
stack,
host_name,
result.stderr or result.stdout,
)
return containers
# Build state map: name -> (state, exit_code)
@@ -350,6 +359,7 @@ async def read_console_file(
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
except Exception as e:
logger.exception("Failed to read file %s from host %s", path, host)
raise HTTPException(status_code=500, detail=str(e)) from e
@@ -373,4 +383,5 @@ async def write_console_file(
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
except Exception as e:
logger.exception("Failed to write file %s to host %s", path, host)
raise HTTPException(status_code=500, detail=str(e)) from e

View File

@@ -1,3 +1,9 @@
/* Tooltips - ensure they appear above sidebar and other elements */
.tooltip::before,
.tooltip::after {
z-index: 1000;
}
/* Sidebar inputs - remove focus outline (DaisyUI 5 uses outline + outline-offset) */
#sidebar .input:focus,
#sidebar .input:focus-within,

View File

@@ -601,7 +601,9 @@ function playFabIntro() {
}
function filter() {
const q = input.value.toLowerCase();
// Normalize: collapse spaces and ensure space after colon for matching
// This allows "theme:dark", "theme: dark", "theme: dark" to all match "theme: dark"
const q = input.value.toLowerCase().replace(/\s+/g, ' ').replace(/:(\S)/g, ': $1');
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
@@ -634,7 +636,7 @@ function playFabIntro() {
input.value = initialFilter;
filter();
// If opening theme picker, select current theme
if (initialFilter === 'theme:') {
if (initialFilter.startsWith('theme:')) {
const currentIdx = filtered.findIndex(c => c.themeId === originalTheme);
if (currentIdx >= 0) selected = currentIdx;
}

View File

@@ -39,7 +39,7 @@
<span class="font-semibold rainbow-hover">Compose Farm</span>
</header>
<main id="main-content" class="flex-1 p-6 overflow-y-auto">
<main id="main-content" class="flex-1 p-6">
{% block content %}{% endblock %}
</main>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% from "partials/components.html" import page_header, collapse, stat_card, table, action_btn %}
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database %}
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database, cloud_download, rotate_cw %}
{% block title %}Dashboard - Compose Farm{% endblock %}
{% block content %}
@@ -17,7 +17,9 @@
<div class="flex flex-wrap gap-2 mb-6">
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
<button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button>
{{ action_btn("Pull All", "/api/pull-all", "outline", "Pull latest images for all stacks", cloud_download()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks (pull + build + down + up)", rotate_cw()) }}
<div class="tooltip" data-tip="Save compose-farm.yaml config file"><button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button></div>
</div>
{% include "partials/terminal.html" %}

View File

@@ -25,12 +25,13 @@
{# Action button with htmx #}
{% macro action_btn(label, url, style="outline", title=None, icon=None) %}
{% if title %}<div class="tooltip" data-tip="{{ title }}">{% endif %}
<button hx-post="{{ url }}"
hx-swap="none"
class="btn btn-{{ style }}"
{% if title %}title="{{ title }}"{% endif %}>
class="btn btn-{{ style }}">
{% if icon %}{{ icon }}{% endif %}{{ label }}
</button>
{% if title %}</div>{% endif %}
{% endmacro %}
{# Stat card for dashboard #}

View File

@@ -18,10 +18,12 @@
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
<code class="text-sm flex-1">{{ container.Name }}</code>
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
<div class="tooltip tooltip-left" data-tip="Open shell in container">
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
</div>
</div>
{% endmacro %}

View File

@@ -30,7 +30,7 @@
<!-- Other -->
{{ action_btn("Pull", "/api/stack/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
{{ action_btn("Logs", "/api/stack/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
<button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button>
<div class="tooltip" data-tip="Save compose and .env files"><button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button></div>
</div>
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}

View File

@@ -6,6 +6,7 @@ import asyncio
import contextlib
import fcntl
import json
import logging
import os
import pty
import shlex
@@ -21,6 +22,8 @@ from compose_farm.executor import is_local, ssh_connect_kwargs
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks
logger = logging.getLogger(__name__)
# Shell command to prefer bash over sh
SHELL_FALLBACK = "command -v bash >/dev/null && exec bash || exec sh"
@@ -214,6 +217,7 @@ async def exec_websocket(
except WebSocketDisconnect:
pass
except Exception as e:
logger.exception("WebSocket exec error for %s on %s", container, host)
with contextlib.suppress(Exception):
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
finally:
@@ -258,6 +262,7 @@ async def shell_websocket(
except WebSocketDisconnect:
pass
except Exception as e:
logger.exception("WebSocket shell error for host %s", host)
with contextlib.suppress(Exception):
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
finally:

View File

@@ -11,6 +11,7 @@ copyright = "Copyright &copy; 2025 Bas Nijholt"
repo_url = "https://github.com/basnijholt/compose-farm"
repo_name = "GitHub"
edit_uri = "edit/main/docs"
extra_javascript = ["javascripts/video-fix.js"]
nav = [
{ "Home" = "index.md" },
@@ -48,16 +49,25 @@ features = [
]
[[project.theme.palette]]
media = "(prefers-color-scheme)"
toggle.icon = "lucide/sun-moon"
toggle.name = "Switch to light mode"
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "teal"
primary = "indigo"
accent = "indigo"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "teal"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"
primary = "indigo"
accent = "orange"
toggle.icon = "lucide/moon-star"
toggle.name = "Switch to system preference"
[project.theme.font]
text = "Inter"
@@ -67,6 +77,9 @@ code = "JetBrains Mono"
logo = "lucide/server"
repo = "lucide/github"
[project.extra]
generator = false
[[project.extra.social]]
icon = "fontawesome/brands/github"
link = "https://github.com/basnijholt/compose-farm"