Compare commits

..

27 Commits

Author SHA1 Message Date
Bas Nijholt
12bbcee374 feat(web): Handle invalid config gracefully with error banner (#16) 2025-12-18 00:40:19 -08:00
Bas Nijholt
6e73ae0157 feat(web): Add command palette with Cmd+K (#15) 2025-12-18 00:12:38 -08:00
Bas Nijholt
d90b951a8c feat(web): Vendor CDN assets at build time for offline use
Add a Hatch build hook that downloads JS/CSS dependencies during wheel
builds and rewrites base.html to use local paths. This allows the web UI
to work in environments without internet access.

- Add data-vendor attributes to base.html for declarative asset mapping
- Download htmx, tailwind, daisyui, and xterm.js during build
- Bundle LICENSES.txt with attribution for vendored dependencies
- Source HTML keeps CDN links for development; wheel has local paths
2025-12-17 23:45:06 -08:00
Bas Nijholt
14558131ed feat(web): Add search and host filter to sidebar and services list
- Add search input and host dropdown to sidebar for filtering services
- Add search input and host dropdown to Services by Host section
- Show dynamic service count in sidebar that updates with filter
- Multi-host services appear when any host is selected
2025-12-17 23:35:18 -08:00
Bas Nijholt
a422363337 Update uv.lock 2025-12-17 23:21:37 -08:00
Bas Nijholt
1278d0b3af fix(web): Remove config caching so changes are detected immediately
Config was cached with @lru_cache, causing the web UI to show stale
sync status after external config file edits.
2025-12-17 23:17:25 -08:00
Bas Nijholt
c8ab6271a8 feat(web): Add rainbow hover effect to Compose Farm headers
Animated gradient appears on hover for both sidebar and mobile navbar headers.
2025-12-17 23:09:57 -08:00
Bas Nijholt
957e828a5b feat(web): Add Lucide icons to web UI (#14) 2025-12-17 23:04:53 -08:00
Bas Nijholt
5afda8cbb2 Add web UI with FastAPI + HTMX + xterm.js (#13) 2025-12-17 22:52:40 -08:00
Bas Nijholt
1bbf324f1e Validate services exist in config with friendly error
Show a clean error message instead of a traceback when a service
is not found in config. Includes a hint about adding to config.
2025-12-17 11:04:48 -08:00
Bas Nijholt
1be5b987a2 Support "." as shorthand for current directory service name
Running `cf up .` now resolves to the current directory name,
allowing quick operations when inside a service directory.
2025-12-17 10:57:48 -08:00
Bas Nijholt
6b684b19f2 Run startup time test 6 times 2025-12-17 09:08:45 -08:00
Bas Nijholt
4a37982e30 Cleanup 2025-12-17 09:07:52 -08:00
Bas Nijholt
55cb44e0e7 Drop service discovery mention 2025-12-17 09:07:30 -08:00
Bas Nijholt
5c242d08bf Add cf apply to post 2025-12-17 09:06:59 -08:00
Bas Nijholt
5bf65d3849 Raise Linux CLI startup threshold to 0.25s for CI headroom 2025-12-17 09:05:53 -08:00
Bas Nijholt
21d5dfa175 Fix check-readme-commands hook to use uv run for CI compatibility 2025-12-17 08:58:45 -08:00
Bas Nijholt
e49ad29999 Use OS-specific thresholds for CLI startup test (Linux: 0.2s, macOS: 0.35s, Windows: 2s) 2025-12-17 08:57:50 -08:00
Bas Nijholt
cdbe74ed89 Return early from CLI startup test when under threshold 2025-12-17 08:56:35 -08:00
Bas Nijholt
129970379c Increase CLI startup threshold to 0.35s for macOS/Windows CI 2025-12-17 08:55:40 -08:00
Bas Nijholt
c5c47d14dd Add CLI startup time test to catch slow imports
Runs `cf --help` and fails if startup exceeds 200ms. Shows timing
info in CI logs on both pass and failure.
2025-12-17 08:53:32 -08:00
Bas Nijholt
95f19e7333 Add pre-commit hook to verify all CLI commands are documented in README
Extracts commands from the Typer app and checks each has a corresponding
--help section in the README. Runs when README.md or CLI files change.
2025-12-17 08:45:16 -08:00
Bas Nijholt
9c6edd3f18 refactor(docs): move reddit-post.md into docs folder 2025-12-17 08:45:16 -08:00
github-actions[bot]
bda9210354 Update README.md 2025-12-17 16:35:24 +00:00
Bas Nijholt
f57951e8dc Fix cf up -h output in README.md 2025-12-17 08:34:52 -08:00
basnijholt
ba8c04caf8 chore(docs): update TOC 2025-12-17 16:31:40 +00:00
Bas Nijholt
ff0658117d Add all --help outputs 2025-12-17 08:31:14 -08:00
49 changed files with 4357 additions and 198 deletions

88
.github/check_readme_commands.py vendored Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Check that all CLI commands are documented in the README."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import typer
from compose_farm.cli import app
def get_all_commands(typer_app: typer.Typer, prefix: str = "cf") -> set[str]:
"""Extract all command names from a Typer app, including nested subcommands."""
commands = set()
# Get registered commands (skip hidden ones like aliases)
for command in typer_app.registered_commands:
if command.hidden:
continue
name = command.name
if not name and command.callback:
name = command.callback.__name__
if name:
commands.add(f"{prefix} {name}")
# Get registered sub-apps (like 'config')
for group in typer_app.registered_groups:
sub_app = group.typer_instance
sub_name = group.name
if sub_app and sub_name:
commands.add(f"{prefix} {sub_name}")
# Don't recurse into subcommands - we only document the top-level subcommand
return commands
def get_documented_commands(readme_path: Path) -> set[str]:
"""Extract commands documented in README from help output sections."""
content = readme_path.read_text()
# Match patterns like: <code>cf command --help</code>
pattern = r"<code>(cf\s+[\w-]+)\s+--help</code>"
matches = re.findall(pattern, content)
return set(matches)
def main() -> int:
"""Check that all CLI commands are documented in the README."""
readme_path = Path(__file__).parent.parent / "README.md"
if not readme_path.exists():
print(f"ERROR: README.md not found at {readme_path}")
return 1
cli_commands = get_all_commands(app)
documented_commands = get_documented_commands(readme_path)
# Also check for the main 'cf' help
if "<code>cf --help</code>" in readme_path.read_text():
documented_commands.add("cf")
cli_commands.add("cf")
missing = cli_commands - documented_commands
extra = documented_commands - cli_commands
if missing or extra:
if missing:
print("ERROR: Commands missing from README --help documentation:")
for cmd in sorted(missing):
print(f" - {cmd}")
if extra:
print("WARNING: Commands documented but not in CLI:")
for cmd in sorted(extra):
print(f" - {cmd}")
return 1
print(f"✓ All {len(cli_commands)} commands documented in README")
return 0
if __name__ == "__main__":
sys.exit(main())

2
.gitignore vendored
View File

@@ -42,3 +42,5 @@ htmlcov/
compose-farm.yaml
!examples/compose-farm.yaml
coverage.xml
.env
homepage/

View File

@@ -1,4 +1,13 @@
repos:
- repo: local
hooks:
- id: check-readme-commands
name: Check README documents all CLI commands
entry: uv run python .github/check_readme_commands.py
language: system
files: ^(README\.md|src/compose_farm/cli/.*)$
pass_filenames: false
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:

View File

@@ -28,6 +28,10 @@ compose_farm/
└── traefik.py # Traefik file-provider config generation from labels
```
## Web UI Icons
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html` by copying SVG paths from their site. The `action_btn`, `stat_card`, and `collapse` macros in `components.html` accept an optional `icon` parameter.
## Key Design Decisions
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)

View File

@@ -6,7 +6,7 @@ RUN apk add --no-cache openssh-client
# Install compose-farm from PyPI
ARG VERSION
RUN uv tool install compose-farm${VERSION:+==$VERSION}
RUN uv tool install "compose-farm[web]${VERSION:+==$VERSION}"
# Add uv tool bin to PATH
ENV PATH="/root/.local/bin:$PATH"

543
README.md
View File

@@ -27,6 +27,7 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [Multi-Host Services](#multi-host-services)
- [Config Command](#config-command)
- [Usage](#usage)
- [CLI `--help` Output](#cli---help-output)
- [Auto-Migration](#auto-migration)
- [Traefik Multihost Ingress (File Provider)](#traefik-multihost-ingress-file-provider)
- [Comparison with Alternatives](#comparison-with-alternatives)
@@ -297,6 +298,10 @@ cf logs -f plex # follow
cf ps
```
### CLI `--help` Output
Full `--help` output for each command. See the [Usage](#usage) table above for a quick overview.
<details>
<summary>See the output of <code>cf --help</code></summary>
@@ -345,6 +350,9 @@ cf ps
│ ps Show status of all services. │
│ stats Show overview statistics for hosts and services. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Server ─────────────────────────────────────────────────────────────────────╮
│ web Start the web UI server. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -352,6 +360,541 @@ cf ps
</details>
**Lifecycle**
<details>
<summary>See the output of <code>cf up --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf up --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf up [OPTIONS] [SERVICES]...
Start services (docker compose up -d). Auto-migrates if host changed.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --host -H TEXT Filter to services on this host │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf down --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf down --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf down [OPTIONS] [SERVICES]...
Stop services (docker compose down).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --orphaned Stop orphaned services (in state but removed from │
│ config) │
│ --host -H TEXT Filter to services on this host │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf pull --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf pull --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf pull [OPTIONS] [SERVICES]...
Pull latest images (docker compose pull).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf restart --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf restart --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf restart [OPTIONS] [SERVICES]...
Restart services (down + up).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf update --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf update --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf update [OPTIONS] [SERVICES]...
Update services (pull + build + down + up).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf apply --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf apply --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf apply [OPTIONS]
Make reality match config (start, migrate, stop as needed).
This is the "reconcile" command that ensures running services match your
config file. It will:
1. Stop orphaned services (in state but removed from config) 2. Migrate
services on wrong host (host in state ≠ host in config) 3. Start missing
services (in config but not in state)
Use --dry-run to preview changes before applying. Use --no-orphans to only
migrate/start without stopping orphaned services. Use --full to also run 'up'
on all services (picks up compose/env changes).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --dry-run -n Show what would change without executing │
│ --no-orphans Only migrate, don't stop orphaned services │
│ --full -f Also run up on all services to apply config │
│ changes │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
**Configuration**
<details>
<summary>See the output of <code>cf traefik-file --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf traefik-file --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf traefik-file [OPTIONS] [SERVICES]...
Generate a Traefik file-provider fragment from compose Traefik labels.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --output -o PATH Write Traefik file-provider YAML to this path │
│ (stdout if omitted) │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf refresh --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf refresh --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf refresh [OPTIONS]
Update local state from running services.
Discovers which services are running on which hosts, updates the state file,
and captures image digests. This is a read operation - it updates your local
state to match reality, not the other way around.
Use 'cf apply' to make reality match your config (stop orphans, migrate).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --config -c PATH Path to config file │
│ --log-path -l PATH Path to Dockerfarm TOML log │
│ --dry-run -n Show what would change without writing │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf check --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf check --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf check [OPTIONS] [SERVICES]...
Validate configuration, traefik labels, mounts, and networks.
Without arguments: validates all services against configured hosts. With
service arguments: validates specific services and shows host compatibility.
Use --local to skip SSH-based checks for faster validation.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --local Skip SSH-based checks (faster) │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf init-network --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf init-network --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf init-network [OPTIONS] [HOSTS]...
Create Docker network on hosts with consistent settings.
Creates an external Docker network that services can use for cross-host
communication. Uses the same subnet/gateway on all hosts to ensure consistent
networking.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ hosts [HOSTS]... Hosts to create network on (default: all) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --network -n TEXT Network name [default: mynetwork] │
│ --subnet -s TEXT Network subnet [default: 172.20.0.0/16] │
│ --gateway -g TEXT Network gateway [default: 172.20.0.1] │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf config --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf config --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf config [OPTIONS] COMMAND [ARGS]...
Manage compose-farm configuration files.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ init Create a new config file with documented example. │
│ edit Open the config file in your default editor. │
│ show Display the config file location and contents. │
│ path Print the config file path (useful for scripting). │
│ validate Validate the config file syntax and schema. │
│ symlink Create a symlink from the default config location to a config │
│ file. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
**Monitoring**
<details>
<summary>See the output of <code>cf logs --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf logs --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf logs [OPTIONS] [SERVICES]...
Show service logs.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services │
│ --host -H TEXT Filter to services on this host │
│ --follow -f Follow logs │
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
│ otherwise) │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf ps --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf ps --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf ps [OPTIONS]
Show status of all services.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf stats --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf stats --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf stats [OPTIONS]
Show overview statistics for hosts and services.
Without --live: Shows config/state info (hosts, services, pending migrations).
With --live: Also queries Docker on each host for container counts.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --live -l Query Docker for live container stats │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
**Server**
<details>
<summary>See the output of <code>cf web --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf web --help -->
<!-- echo '```' -->
<!-- CODE:END -->
</details>
### Auto-Migration
When you change a service's host assignment in config and run `up`, Compose Farm automatically:

View File

@@ -4,8 +4,31 @@ services:
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
# Compose directory (contains compose files AND compose-farm.yaml config)
- ${CF_COMPOSE_DIR:-/opt/compose}:${CF_COMPOSE_DIR:-/opt/compose}
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
environment:
- SSH_AUTH_SOCK=/ssh-agent
# Config file path (state stored alongside it)
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/compose}/compose-farm.yaml
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
web:
image: ghcr.io/basnijholt/compose-farm:latest
command: web --host 0.0.0.0 --port 9000
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
environment:
- SSH_AUTH_SOCK=/ssh-agent
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
labels:
- traefik.enable=true
- traefik.http.routers.compose-farm.rule=Host(`compose-farm.${DOMAIN}`)
- traefik.http.routers.compose-farm.entrypoints=websecure
- traefik.http.routers.compose-farm-local.rule=Host(`compose-farm.local`)
- traefik.http.routers.compose-farm-local.entrypoints=web
- traefik.http.services.compose-farm.loadbalancer.server.port=9000
networks:
- mynetwork
networks:
mynetwork:
external: true

View File

@@ -2,11 +2,9 @@
- Multi-host Docker Compose without Kubernetes or file changes
- I built a CLI to run Docker Compose across hosts. Zero changes to your files.
- I made a CLI to run Docker Compose across multiple hosts without Kubernetes or Swarm
---
# I made a CLI to run Docker Compose across multiple hosts without Kubernetes or Swarm
I've been running 100+ Docker Compose stacks on a single machine, and it kept running out of memory. I needed to spread services across multiple hosts, but:
- **Kubernetes** felt like overkill. I don't need pods, ingress controllers, or 10x more YAML.
@@ -37,6 +35,7 @@ Then just:
```bash
cf up plex # runs on nuc via SSH
cf apply # makes config state match desired state on all hosts (like Terraform apply)
cf up --all # starts everything on their assigned hosts
cf logs -f plex # streams logs
cf ps # shows status across all hosts
@@ -68,7 +67,6 @@ cf up plex # migrates automatically
- No high availability (if a host goes down, services don't auto-migrate)
- No overlay networking (containers on different hosts can't talk via Docker DNS)
- No service discovery
- No health checks or automatic restarts
It's a convenience wrapper around `docker compose` + SSH. If you need failover or cross-host container networking, you probably do need Swarm or Kubernetes.
@@ -78,4 +76,4 @@ It's a convenience wrapper around `docker compose` + SSH. If you need failover o
- GitHub: https://github.com/basnijholt/compose-farm
- Install: `uv tool install compose-farm` or `pip install compose-farm`
Built this in 4 days because I was mass-SSHing into machines like a caveman. Happy to answer questions or take feedback!
Happy to answer questions or take feedback!

170
hatch_build.py Normal file
View File

@@ -0,0 +1,170 @@
"""Hatch build hook to vendor CDN assets for offline use.
During wheel builds, this hook:
1. Parses base.html to find elements with data-vendor attributes
2. Downloads each CDN asset to a temporary vendor directory
3. Rewrites base.html to use local /static/vendor/ paths
4. Fetches and bundles license information
5. Includes everything in the wheel via force_include
The source base.html keeps CDN links for development; only the
distributed wheel has vendored assets.
"""
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from urllib.request import Request, urlopen
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
# Matches elements with data-vendor attribute: extracts URL and target filename
# Example: <script src="https://..." data-vendor="htmx.js">
# Captures: (1) src/href, (2) URL, (3) attributes between, (4) vendor filename
VENDOR_PATTERN = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
# License URLs for each package (GitHub raw URLs)
LICENSE_URLS: dict[str, tuple[str, str]] = {
"htmx": ("MIT", "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"),
"xterm": ("MIT", "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"),
"daisyui": ("MIT", "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"),
"tailwindcss": (
"MIT",
"https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE",
),
}
def _download(url: str) -> bytes:
"""Download a URL, trying urllib first then curl as fallback."""
# Try urllib first
try:
req = Request( # noqa: S310
url, headers={"User-Agent": "Mozilla/5.0 (compatible; compose-farm build)"}
)
with urlopen(req, timeout=30) as resp: # noqa: S310
return resp.read() # type: ignore[no-any-return]
except Exception: # noqa: S110
pass # Fall through to curl
# Fallback to curl (handles SSL proxies better)
result = subprocess.run(
["curl", "-fsSL", "--max-time", "30", url], # noqa: S607
capture_output=True,
check=True,
)
return bytes(result.stdout)
def _generate_licenses_file(temp_dir: Path) -> None:
"""Download and combine license files into LICENSES.txt."""
lines = [
"# Vendored Dependencies - License Information",
"",
"This file contains license information for JavaScript/CSS libraries",
"bundled with compose-farm for offline use.",
"",
"=" * 70,
"",
]
for pkg_name, (license_type, license_url) in LICENSE_URLS.items():
lines.append(f"## {pkg_name} ({license_type})")
lines.append(f"Source: {license_url}")
lines.append("")
lines.append(_download(license_url).decode("utf-8"))
lines.append("")
lines.append("=" * 70)
lines.append("")
(temp_dir / "LICENSES.txt").write_text("\n".join(lines))
class VendorAssetsHook(BuildHookInterface): # type: ignore[misc]
"""Hatch build hook that vendors CDN assets into the wheel."""
PLUGIN_NAME = "vendor-assets"
def initialize(
self,
_version: str,
build_data: dict[str, Any],
) -> None:
"""Download CDN assets and prepare them for inclusion in the wheel."""
# Only run for wheel builds
if self.target_name != "wheel":
return
# Paths
src_dir = Path(self.root) / "src" / "compose_farm"
base_html_path = src_dir / "web" / "templates" / "base.html"
if not base_html_path.exists():
return
# Create temp directory for vendored assets
temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_"))
vendor_dir = temp_dir / "vendor"
vendor_dir.mkdir()
# Read and parse base.html
html_content = base_html_path.read_text()
url_to_filename: dict[str, str] = {}
# Find all elements with data-vendor attribute and download them
for match in VENDOR_PATTERN.finditer(html_content):
url = match.group(2)
filename = match.group(4)
if url in url_to_filename:
continue
url_to_filename[url] = filename
content = _download(url)
(vendor_dir / filename).write_bytes(content)
if not url_to_filename:
return
# Generate LICENSES.txt
_generate_licenses_file(vendor_dir)
# Rewrite HTML to use local paths (remove data-vendor, update URL)
def replace_vendor_tag(match: re.Match[str]) -> str:
attr = match.group(1) # src or href
url = match.group(2)
between = match.group(3) # attributes between URL and data-vendor
filename = match.group(4)
if url in url_to_filename:
return f'{attr}="/static/vendor/{filename}"{between}'
return match.group(0)
modified_html = VENDOR_PATTERN.sub(replace_vendor_tag, html_content)
# Write modified base.html to temp
templates_dir = temp_dir / "templates"
templates_dir.mkdir()
(templates_dir / "base.html").write_text(modified_html)
# Add to force_include to override files in the wheel
force_include = build_data.setdefault("force_include", {})
force_include[str(vendor_dir)] = "compose_farm/web/static/vendor"
force_include[str(templates_dir / "base.html")] = "compose_farm/web/templates/base.html"
# Store temp_dir path for cleanup
self._temp_dir = temp_dir
def finalize(
self,
_version: str,
_build_data: dict[str, Any],
_artifact_path: str,
) -> None:
"""Clean up temporary directory after build."""
if hasattr(self, "_temp_dir") and self._temp_dir.exists():
shutil.rmtree(self._temp_dir, ignore_errors=True)

View File

@@ -48,6 +48,13 @@ dependencies = [
"rich>=13.0.0",
]
[project.optional-dependencies]
web = [
"fastapi[standard]>=0.109.0",
"jinja2>=3.1.0",
"websockets>=12.0",
]
[project.urls]
Homepage = "https://github.com/basnijholt/compose-farm"
Repository = "https://github.com/basnijholt/compose-farm"
@@ -72,6 +79,9 @@ version-file = "src/compose_farm/_version.py"
[tool.hatch.build.targets.wheel]
packages = ["src/compose_farm"]
[tool.hatch.build.hooks.custom]
# Vendors CDN assets (JS/CSS) into the wheel for offline use
[tool.ruff]
target-version = "py311"
line-length = 100
@@ -101,7 +111,7 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "PLR2004", "S108", "D102", "D103"] # relaxed docstrings + asserts in tests
"tests/*" = ["S101", "PLR2004", "S108", "D102", "D103", "PLC0415", "ARG001", "ARG002", "TC003"] # relaxed for tests
[tool.ruff.lint.mccabe]
max-complexity = 18
@@ -119,6 +129,10 @@ ignore_missing_imports = true
module = "tests.*"
disallow_untyped_decorators = false
[[tool.mypy.overrides]]
module = "compose_farm.web.*"
disallow_untyped_decorators = false
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -153,4 +167,11 @@ dev = [
"ruff>=0.14.8",
"types-pyyaml>=6.0.12.20250915",
"markdown-code-runner>=0.7.0",
# Web deps for type checking (these ship with inline types)
"fastapi>=0.109.0",
"uvicorn[standard]>=0.27.0",
"jinja2>=3.1.0",
"websockets>=12.0",
# For FastAPI TestClient
"httpx>=0.28.0",
]

View File

@@ -8,6 +8,7 @@ from compose_farm.cli import (
lifecycle, # noqa: F401
management, # noqa: F401
monitoring, # noqa: F401
web, # noqa: F401
)
# Import the shared app instance

View File

@@ -57,7 +57,9 @@ _STATS_PREVIEW_LIMIT = 3 # Max number of pending migrations to show by name
@contextlib.contextmanager
def progress_bar(label: str, total: int) -> Generator[tuple[Progress, TaskID], None, None]:
def progress_bar(
label: str, total: int, *, initial_description: str = "[dim]connecting...[/]"
) -> Generator[tuple[Progress, TaskID], None, None]:
"""Create a standardized progress bar with consistent styling.
Yields (progress, task_id). Use progress.update(task_id, advance=1, description=...)
@@ -75,7 +77,7 @@ def progress_bar(label: str, total: int) -> Generator[tuple[Progress, TaskID], N
console=console,
transient=True,
) as progress:
task_id = progress.add_task("", total=total)
task_id = progress.add_task(initial_description, total=total)
yield progress, task_id
@@ -96,7 +98,10 @@ def get_services(
all_services: bool,
config_path: Path | None,
) -> tuple[list[str], Config]:
"""Resolve service list and load config."""
"""Resolve service list and load config.
Supports "." as shorthand for the current directory name.
"""
config = load_config_or_exit(config_path)
if all_services:
@@ -104,7 +109,19 @@ def get_services(
if not services:
err_console.print("[red]✗[/] Specify services or use --all")
raise typer.Exit(1)
return list(services), config
# Resolve "." to current directory name
resolved = [Path.cwd().name if svc == "." else svc for svc in services]
# Validate all services exist in config
unknown = [svc for svc in resolved if svc not in config.services]
if unknown:
for svc in unknown:
err_console.print(f"[red]✗[/] Unknown service: [cyan]{svc}[/]")
err_console.print("[dim]Hint: Add the service to compose-farm.yaml or use --all[/]")
raise typer.Exit(1)
return resolved, config
def run_async(coro: Coroutine[None, None, _T]) -> _T:

View File

@@ -15,7 +15,7 @@ import typer
from compose_farm.cli.app import app
from compose_farm.console import console, err_console
from compose_farm.paths import config_search_paths, default_config_path
from compose_farm.paths import config_search_paths, default_config_path, find_config_path
config_app = typer.Typer(
name="config",
@@ -76,18 +76,8 @@ def _get_config_file(path: Path | None) -> Path | None:
if path:
return path.expanduser().resolve()
# Check environment variable
if env_path := os.environ.get("CF_CONFIG"):
p = Path(env_path)
if p.exists():
return p.resolve()
# Check standard locations
for p in config_search_paths():
if p.exists():
return p.resolve()
return None
config_path = find_config_path()
return config_path.resolve() if config_path else None
@config_app.command("init")

View File

@@ -29,7 +29,6 @@ if TYPE_CHECKING:
from compose_farm.console import console, err_console
from compose_farm.executor import (
CommandResult,
check_service_running,
is_local,
run_command,
)
@@ -42,7 +41,11 @@ from compose_farm.logs import (
merge_entries,
write_toml,
)
from compose_farm.operations import check_host_compatibility, check_service_requirements
from compose_farm.operations import (
check_host_compatibility,
check_service_requirements,
discover_service_host,
)
from compose_farm.state import get_orphaned_services, load_state, save_state
from compose_farm.traefik import generate_traefik_config, render_traefik_config
@@ -52,41 +55,10 @@ from compose_farm.traefik import generate_traefik_config, render_traefik_config
def _discover_services(cfg: Config) -> dict[str, str | list[str]]:
"""Discover running services with a progress bar."""
async def check_service(service: str) -> tuple[str, str | list[str] | None]:
"""Check where a service is running.
For multi-host services, returns list of hosts where running.
For single-host, returns single host name or None.
"""
assigned_hosts = cfg.get_hosts(service)
if cfg.is_multi_host(service):
# Multi-host: find all hosts where running (check in parallel)
checks = await asyncio.gather(
*[check_service_running(cfg, service, h) for h in assigned_hosts]
)
running_hosts = [
h for h, running in zip(assigned_hosts, checks, strict=True) if running
]
return service, running_hosts if running_hosts else None
# Single-host: check assigned host first
assigned_host = assigned_hosts[0]
if await check_service_running(cfg, service, assigned_host):
return service, assigned_host
# Check other hosts
for host_name in cfg.hosts:
if host_name == assigned_host:
continue
if await check_service_running(cfg, service, host_name):
return service, host_name
return service, None
async def gather_with_progress(
progress: Progress, task_id: TaskID
) -> dict[str, str | list[str]]:
services = list(cfg.services.keys())
tasks = [asyncio.create_task(check_service(s)) for s in services]
tasks = [asyncio.create_task(discover_service_host(cfg, s)) for s in cfg.services]
discovered: dict[str, str | list[str]] = {}
for coro in asyncio.as_completed(tasks):
service, host = await coro
@@ -267,7 +239,9 @@ def _check_service_requirements(
return all_mount_errors, all_network_errors, all_device_errors
with progress_bar("Checking requirements", len(services)) as (progress, task_id):
with progress_bar(
"Checking requirements", len(services), initial_description="[dim]checking...[/]"
) as (progress, task_id):
return asyncio.run(gather_with_progress(progress, task_id))

View File

@@ -0,0 +1,48 @@
"""Web server command."""
from __future__ import annotations
from typing import Annotated
import typer
from compose_farm.cli.app import app
from compose_farm.console import console
@app.command(rich_help_panel="Server")
def web(
host: Annotated[
str,
typer.Option("--host", "-H", help="Host to bind to"),
] = "0.0.0.0", # noqa: S104
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to listen on"),
] = 8000,
reload: Annotated[
bool,
typer.Option("--reload", "-r", help="Enable auto-reload for development"),
] = False,
) -> None:
"""Start the web UI server."""
try:
import uvicorn # noqa: PLC0415
except ImportError:
console.print(
"[red]Error:[/] Web dependencies not installed. "
"Install with: [cyan]pip install compose-farm[web][/]"
)
raise typer.Exit(1) from None
console.print(f"[green]Starting Compose Farm Web UI[/] at http://{host}:{port}")
console.print("[dim]Press Ctrl+C to stop[/]")
uvicorn.run(
"compose_farm.web:create_app",
factory=True,
host=host,
port=port,
reload=reload,
log_level="info",
)

View File

@@ -3,13 +3,12 @@
from __future__ import annotations
import getpass
import os
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, model_validator
from .paths import xdg_config_home
from .paths import config_search_paths, find_config_path
class Host(BaseModel):
@@ -150,24 +149,10 @@ def load_config(path: Path | None = None) -> Config:
3. ./compose-farm.yaml
4. $XDG_CONFIG_HOME/compose-farm/compose-farm.yaml (defaults to ~/.config)
"""
search_paths = [
Path("compose-farm.yaml"),
xdg_config_home() / "compose-farm" / "compose-farm.yaml",
]
if path:
config_path = path
elif env_path := os.environ.get("CF_CONFIG"):
config_path = Path(env_path)
else:
config_path = None
for p in search_paths:
if p.exists():
config_path = p
break
config_path = path or find_config_path()
if config_path is None or not config_path.exists():
msg = f"Config file not found. Searched: {', '.join(str(p) for p in search_paths)}"
msg = f"Config file not found. Searched: {', '.join(str(p) for p in config_search_paths())}"
raise FileNotFoundError(msg)
if config_path.is_dir():

View File

@@ -220,7 +220,16 @@ async def run_command(
stream: bool = True,
raw: bool = False,
) -> CommandResult:
"""Run a command on a host (locally or via SSH)."""
"""Run a command on a host (locally or via SSH).
Args:
host: Host configuration
command: Command to run
service: Service name (used as prefix in output)
stream: Whether to stream output (default True)
raw: Whether to use raw mode with TTY (default False)
"""
if is_local(host):
return await _run_local_command(command, service, stream=stream, raw=raw)
return await _run_ssh_command(host, command, service, stream=stream, raw=raw)

View File

@@ -76,6 +76,33 @@ def get_service_paths(cfg: Config, service: str) -> list[str]:
return paths
async def discover_service_host(cfg: Config, service: str) -> tuple[str, str | list[str] | None]:
"""Discover where a service is running.
For multi-host services, checks all assigned hosts in parallel.
For single-host, checks assigned host first, then others.
Returns (service_name, host_or_hosts_or_none).
"""
assigned_hosts = cfg.get_hosts(service)
if cfg.is_multi_host(service):
# Check all assigned hosts in parallel
checks = await asyncio.gather(
*[check_service_running(cfg, service, h) for h in assigned_hosts]
)
running = [h for h, is_running in zip(assigned_hosts, checks, strict=True) if is_running]
return service, running if running else None
# Single-host: check assigned host first, then others
if await check_service_running(cfg, service, assigned_hosts[0]):
return service, assigned_hosts[0]
for host in cfg.hosts:
if host != assigned_hosts[0] and await check_service_running(cfg, service, host):
return service, host
return service, None
async def check_service_requirements(
cfg: Config,
service: str,

View File

@@ -19,3 +19,15 @@ def default_config_path() -> Path:
def config_search_paths() -> list[Path]:
"""Get search paths for config files."""
return [Path("compose-farm.yaml"), default_config_path()]
def find_config_path() -> Path | None:
"""Find the config file path, checking CF_CONFIG env var and search paths."""
if env_path := os.environ.get("CF_CONFIG"):
p = Path(env_path)
if p.exists() and p.is_file():
return p
for p in config_search_paths():
if p.exists() and p.is_file():
return p
return None

View File

@@ -0,0 +1,7 @@
"""Compose Farm Web UI."""
from __future__ import annotations
from compose_farm.web.app import create_app
__all__ = ["create_app"]

View File

@@ -0,0 +1,51 @@
"""FastAPI application setup."""
from __future__ import annotations
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# Startup: pre-load config (ignore errors - handled per-request)
with suppress(ValidationError, FileNotFoundError):
get_config()
yield
# Shutdown: nothing to clean up
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="Compose Farm",
description="Web UI for managing Docker Compose services across multiple hosts",
lifespan=lifespan,
)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(pages.router)
app.include_router(api.router, prefix="/api")
app.include_router(actions.router, prefix="/api")
# WebSocket routes use Unix-only modules (fcntl, pty)
if sys.platform != "win32":
from compose_farm.web.ws import router as ws_router # noqa: PLC0415
app.include_router(ws_router)
return app

View File

@@ -0,0 +1,32 @@
"""Shared dependencies for web modules.
This module contains shared config and template accessors to avoid circular imports
between app.py and route modules.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi.templating import Jinja2Templates
if TYPE_CHECKING:
from compose_farm.config import Config
# Paths
WEB_DIR = Path(__file__).parent
TEMPLATES_DIR = WEB_DIR / "templates"
STATIC_DIR = WEB_DIR / "static"
def get_config() -> Config:
"""Load config from disk (always fresh)."""
from compose_farm.config import load_config # noqa: PLC0415
return load_config()
def get_templates() -> Jinja2Templates:
"""Get Jinja2 templates instance."""
return Jinja2Templates(directory=str(TEMPLATES_DIR))

View File

@@ -0,0 +1,5 @@
"""Web routes."""
from compose_farm.web.routes import actions, api, pages
__all__ = ["actions", "api", "pages"]

View File

@@ -0,0 +1,95 @@
"""Action routes for service operations."""
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter, HTTPException
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import run_cli_streaming, run_compose_streaming, tasks
router = APIRouter(tags=["actions"])
# Store task references to prevent garbage collection
_background_tasks: set[asyncio.Task[None]] = set()
def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str:
"""Create a task, register it, and return the task_id."""
task_id = str(uuid.uuid4())
tasks[task_id] = {"status": "running", "output": []}
task: asyncio.Task[None] = asyncio.create_task(coro_factory(task_id))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task_id
async def _run_service_action(name: str, command: str) -> dict[str, Any]:
"""Run a compose command for a service."""
config = get_config()
if name not in config.services:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
task_id = _start_task(lambda tid: run_compose_streaming(config, name, command, tid))
return {"task_id": task_id, "service": name, "command": command}
@router.post("/service/{name}/up")
async def up_service(name: str) -> dict[str, Any]:
"""Start a service."""
return await _run_service_action(name, "up")
@router.post("/service/{name}/down")
async def down_service(name: str) -> dict[str, Any]:
"""Stop a service."""
return await _run_service_action(name, "down")
@router.post("/service/{name}/restart")
async def restart_service(name: str) -> dict[str, Any]:
"""Restart a service (down + up)."""
return await _run_service_action(name, "restart")
@router.post("/service/{name}/pull")
async def pull_service(name: str) -> dict[str, Any]:
"""Pull latest images for a service."""
return await _run_service_action(name, "pull")
@router.post("/service/{name}/update")
async def update_service(name: str) -> dict[str, Any]:
"""Update a service (pull + build + down + up)."""
return await _run_service_action(name, "update")
@router.post("/service/{name}/logs")
async def logs_service(name: str) -> dict[str, Any]:
"""Show logs for a service."""
return await _run_service_action(name, "logs")
@router.post("/apply")
async def apply_all() -> dict[str, Any]:
"""Run cf apply to reconcile all services."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["apply"], tid))
return {"task_id": task_id, "command": "apply"}
@router.post("/refresh")
async def refresh_state() -> dict[str, Any]:
"""Refresh state from running services."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
return {"task_id": task_id, "command": "refresh"}

View File

@@ -0,0 +1,212 @@
"""JSON API routes."""
from __future__ import annotations
import contextlib
import json
from typing import TYPE_CHECKING, Annotated, Any
import yaml
if TYPE_CHECKING:
from pathlib import Path
from fastapi import APIRouter, Body, HTTPException
from fastapi.responses import HTMLResponse
from compose_farm.executor import run_compose_on_host
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
router = APIRouter(tags=["api"])
def _validate_yaml(content: str) -> None:
"""Validate YAML content, raise HTTPException on error."""
try:
yaml.safe_load(content)
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") from e
def _get_service_compose_path(name: str) -> Path:
"""Get compose path for service, raising HTTPException if not found."""
config = get_config()
if name not in config.services:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
compose_path = config.get_compose_path(name)
if not compose_path:
raise HTTPException(status_code=404, detail="Compose file not found")
return compose_path
def _get_compose_services(config: Any, service: str, hosts: list[str]) -> list[dict[str, Any]]:
"""Get container info from compose file (fast, local read).
Returns one entry per container per host for multi-host services.
"""
compose_path = config.get_compose_path(service)
if not compose_path or not compose_path.exists():
return []
compose_data = yaml.safe_load(compose_path.read_text()) or {}
raw_services = compose_data.get("services", {})
if not isinstance(raw_services, dict):
return []
# Project name is the directory name (docker compose default)
project_name = compose_path.parent.name
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,
"Service": svc_name,
"Host": host,
"State": "unknown", # Status requires Docker query
}
)
return containers
async def _get_container_states(
config: Any, service: str, containers: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Query Docker for actual container states on a single host."""
if not containers:
return containers
# All containers should be on the same host
host_name = containers[0]["Host"]
result = await run_compose_on_host(config, service, host_name, "ps --format json", stream=False)
if not result.success:
return containers
# Build state map
state_map: dict[str, str] = {}
for line in result.stdout.strip().split("\n"):
if line.strip():
with contextlib.suppress(json.JSONDecodeError):
data = json.loads(line)
state_map[data.get("Name", "")] = data.get("State", "unknown")
# Update container states
for c in containers:
if c["Name"] in state_map:
c["State"] = state_map[c["Name"]]
return containers
def _render_containers(
service: str, host: str, containers: list[dict[str, Any]], *, show_header: bool = False
) -> str:
"""Render containers HTML using Jinja template."""
templates = get_templates()
template = templates.env.get_template("partials/containers.html")
module = template.make_module()
result: str = module.host_containers(service, host, containers, show_header=show_header)
return result
@router.get("/service/{name}/containers", response_class=HTMLResponse)
async def get_containers(name: str, host: str | None = None) -> HTMLResponse:
"""Get containers for a service as HTML buttons.
If host is specified, queries Docker for that host's status.
Otherwise returns all hosts with loading spinners that auto-fetch.
"""
config = get_config()
if name not in config.services:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
# Get hosts where service is running from state
state = load_state(config)
current_hosts = state.get(name)
if not current_hosts:
return HTMLResponse('<span class="text-base-content/60">Service not running</span>')
all_hosts = current_hosts if isinstance(current_hosts, list) else [current_hosts]
# If host specified, return just that host's containers with status
if host:
if host not in all_hosts:
return HTMLResponse(f'<span class="text-error">Host {host} not found</span>')
containers = _get_compose_services(config, name, [host])
containers = await _get_container_states(config, name, containers)
return HTMLResponse(_render_containers(name, host, containers))
# Initial load: return all hosts with loading spinners, each fetches its own status
html_parts = []
is_multi_host = len(all_hosts) > 1
for h in all_hosts:
host_id = f"containers-{name}-{h}".replace(".", "-")
containers = _get_compose_services(config, name, [h])
if is_multi_host:
html_parts.append(f'<div class="font-semibold text-sm mt-3 mb-1">{h}</div>')
# Container for this host that auto-fetches its own status
html_parts.append(f"""
<div id="{host_id}"
hx-get="/api/service/{name}/containers?host={h}"
hx-trigger="load"
hx-target="this"
hx-select="unset"
hx-swap="innerHTML">
{_render_containers(name, h, containers)}
</div>
""")
return HTMLResponse("".join(html_parts))
@router.put("/service/{name}/compose")
async def save_compose(
name: str, content: Annotated[str, Body(media_type="text/plain")]
) -> dict[str, Any]:
"""Save compose file content."""
compose_path = _get_service_compose_path(name)
_validate_yaml(content)
compose_path.write_text(content)
return {"success": True, "message": "Compose file saved"}
@router.put("/service/{name}/env")
async def save_env(
name: str, content: Annotated[str, Body(media_type="text/plain")]
) -> dict[str, Any]:
"""Save .env file content."""
env_path = _get_service_compose_path(name).parent / ".env"
env_path.write_text(content)
return {"success": True, "message": ".env file saved"}
@router.put("/config")
async def save_config(
content: Annotated[str, Body(media_type="text/plain")],
) -> dict[str, Any]:
"""Save compose-farm.yaml config file."""
config_path = find_config_path()
if not config_path:
raise HTTPException(status_code=404, detail="Config file not found")
_validate_yaml(content)
config_path.write_text(content)
return {"success": True, "message": "Config saved"}

View File

@@ -0,0 +1,267 @@
"""HTML page routes."""
from __future__ import annotations
import yaml
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import ValidationError
from compose_farm.paths import find_config_path
from compose_farm.state import (
get_orphaned_services,
get_service_host,
get_services_needing_migration,
get_services_not_in_state,
load_state,
)
from compose_farm.web.deps import get_config, get_templates
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Dashboard page - combined view of all cluster info."""
templates = get_templates()
# Try to load config, handle errors gracefully
config_error = None
try:
config = get_config()
except (ValidationError, FileNotFoundError) as e:
# Extract error message
if isinstance(e, ValidationError):
config_error = "; ".join(err.get("msg", str(err)) for err in e.errors())
else:
config_error = str(e)
# Read raw config content for the editor
config_path = find_config_path()
config_content = config_path.read_text() if config_path else ""
return templates.TemplateResponse(
"index.html",
{
"request": request,
"config_error": config_error,
"hosts": {},
"services": {},
"config_content": config_content,
"state_content": "",
"running_count": 0,
"stopped_count": 0,
"orphaned": [],
"migrations": [],
"not_started": [],
"services_by_host": {},
},
)
# Get state
deployed = load_state(config)
# Stats
running_count = len(deployed)
stopped_count = len(config.services) - running_count
# Pending operations
orphaned = get_orphaned_services(config)
migrations = get_services_needing_migration(config)
not_started = get_services_not_in_state(config)
# Group services by host
services_by_host: dict[str, list[str]] = {}
for svc, host in deployed.items():
if isinstance(host, list):
for h in host:
services_by_host.setdefault(h, []).append(svc)
else:
services_by_host.setdefault(host, []).append(svc)
# Config file content
config_content = ""
if config.config_path and config.config_path.exists():
config_content = config.config_path.read_text()
# State file content
state_content = yaml.dump({"deployed": deployed}, default_flow_style=False, sort_keys=False)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"config_error": None,
# Config data
"hosts": config.hosts,
"services": config.services,
"config_content": config_content,
# State data
"state_content": state_content,
# Stats
"running_count": running_count,
"stopped_count": stopped_count,
# Pending operations
"orphaned": orphaned,
"migrations": migrations,
"not_started": not_started,
# Services by host
"services_by_host": services_by_host,
},
)
@router.get("/service/{name}", response_class=HTMLResponse)
async def service_detail(request: Request, name: str) -> HTMLResponse:
"""Service detail page."""
config = get_config()
templates = get_templates()
# Get compose file content
compose_path = config.get_compose_path(name)
compose_content = ""
if compose_path and compose_path.exists():
compose_content = compose_path.read_text()
# Get .env file content
env_content = ""
env_path = None
if compose_path:
env_path = compose_path.parent / ".env"
if env_path.exists():
env_content = env_path.read_text()
# Get host info
hosts = config.get_hosts(name)
# Get state
current_host = get_service_host(config, name)
return templates.TemplateResponse(
"service.html",
{
"request": request,
"name": name,
"hosts": hosts,
"current_host": current_host,
"compose_content": compose_content,
"compose_path": str(compose_path) if compose_path else None,
"env_content": env_content,
"env_path": str(env_path) if env_path else None,
},
)
@router.get("/partials/sidebar", response_class=HTMLResponse)
async def sidebar_partial(request: Request) -> HTMLResponse:
"""Sidebar service list partial."""
config = get_config()
templates = get_templates()
state = load_state(config)
# Build service -> host mapping (empty string for multi-host services)
service_hosts = {
svc: "" if host_val == "all" or isinstance(host_val, list) else host_val
for svc, host_val in config.services.items()
}
return templates.TemplateResponse(
"partials/sidebar.html",
{
"request": request,
"services": sorted(config.services.keys()),
"service_hosts": service_hosts,
"hosts": sorted(config.hosts.keys()),
"state": state,
},
)
@router.get("/partials/config-error", response_class=HTMLResponse)
async def config_error_partial(request: Request) -> HTMLResponse:
"""Config error banner partial."""
templates = get_templates()
try:
get_config()
return HTMLResponse("") # No error
except (ValidationError, FileNotFoundError) as e:
if isinstance(e, ValidationError):
error = "; ".join(err.get("msg", str(err)) for err in e.errors())
else:
error = str(e)
return templates.TemplateResponse(
"partials/config_error.html", {"request": request, "config_error": error}
)
@router.get("/partials/stats", response_class=HTMLResponse)
async def stats_partial(request: Request) -> HTMLResponse:
"""Stats cards partial."""
config = get_config()
templates = get_templates()
deployed = load_state(config)
running_count = len(deployed)
stopped_count = len(config.services) - running_count
return templates.TemplateResponse(
"partials/stats.html",
{
"request": request,
"hosts": config.hosts,
"services": config.services,
"running_count": running_count,
"stopped_count": stopped_count,
},
)
@router.get("/partials/pending", response_class=HTMLResponse)
async def pending_partial(request: Request, expanded: bool = True) -> HTMLResponse:
"""Pending operations partial."""
config = get_config()
templates = get_templates()
orphaned = get_orphaned_services(config)
migrations = get_services_needing_migration(config)
not_started = get_services_not_in_state(config)
return templates.TemplateResponse(
"partials/pending.html",
{
"request": request,
"orphaned": orphaned,
"migrations": migrations,
"not_started": not_started,
"expanded": expanded,
},
)
@router.get("/partials/services-by-host", response_class=HTMLResponse)
async def services_by_host_partial(request: Request, expanded: bool = True) -> HTMLResponse:
"""Services by host partial."""
config = get_config()
templates = get_templates()
deployed = load_state(config)
# Group services by host
services_by_host: dict[str, list[str]] = {}
for svc, host in deployed.items():
if isinstance(host, list):
for h in host:
services_by_host.setdefault(h, []).append(svc)
else:
services_by_host.setdefault(host, []).append(svc)
return templates.TemplateResponse(
"partials/services_by_host.html",
{
"request": request,
"hosts": config.hosts,
"services_by_host": services_by_host,
"expanded": expanded,
},
)

View File

@@ -0,0 +1,55 @@
/* Editors (Monaco) - wrapper makes it resizable */
.editor-wrapper {
resize: vertical;
overflow: hidden;
min-height: 150px;
}
.editor-wrapper .yaml-editor,
.editor-wrapper .env-editor,
.editor-wrapper .yaml-viewer {
height: 100%;
border: 1px solid oklch(var(--bc) / 0.2);
border-radius: 0.5rem;
}
.editor-wrapper.yaml-wrapper { height: 400px; }
.editor-wrapper.env-wrapper { height: 250px; }
.editor-wrapper.viewer-wrapper { height: 300px; }
/* Terminal - no custom CSS needed, using h-full class in HTML */
/* Prevent save button resize when text changes */
#save-btn, #save-config-btn {
min-width: 5rem;
}
/* Rainbow hover effect for headers */
.rainbow-hover {
transition: color 0.3s;
}
.rainbow-hover:hover {
background: linear-gradient(
90deg,
#e07070,
#e0a070,
#d0d070,
#70c080,
#7090d0,
#9080b0,
#b080a0,
#e07070
);
background-size: 16em 100%;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
animation: rainbow 4s linear infinite;
}
@keyframes rainbow {
to {
background-position: 16em center;
}
}

View File

@@ -0,0 +1,546 @@
/**
* Compose Farm Web UI JavaScript
*/
// ANSI escape codes for terminal output
const ANSI = {
RED: '\x1b[31m',
GREEN: '\x1b[32m',
DIM: '\x1b[2m',
RESET: '\x1b[0m',
CRLF: '\r\n'
};
// Store active terminals and editors
const terminals = {};
const editors = {};
let monacoLoaded = false;
let monacoLoading = false;
// Terminal color theme (dark mode matching PicoCSS)
const TERMINAL_THEME = {
background: '#1a1a2e',
foreground: '#e4e4e7',
cursor: '#e4e4e7',
cursorAccent: '#1a1a2e',
black: '#18181b',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#e4e4e7',
brightBlack: '#52525b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#fafafa'
};
/**
* Create a terminal with fit addon and resize observer
* @param {HTMLElement} container - Container element
* @param {object} extraOptions - Additional terminal options
* @param {function} onResize - Optional callback called with (cols, rows) after resize
* @returns {{term: Terminal, fitAddon: FitAddon}}
*/
function createTerminal(container, extraOptions = {}, onResize = null) {
container.innerHTML = '';
const term = new Terminal({
convertEol: true,
theme: TERMINAL_THEME,
fontSize: 13,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
scrollback: 5000,
...extraOptions
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
const handleResize = () => {
fitAddon.fit();
if (onResize) {
onResize(term.cols, term.rows);
}
};
window.addEventListener('resize', handleResize);
new ResizeObserver(handleResize).observe(container);
return { term, fitAddon };
}
/**
* Create WebSocket connection with standard handlers
* @param {string} path - WebSocket path
* @returns {WebSocket}
*/
function createWebSocket(path) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return new WebSocket(`${protocol}//${window.location.host}${path}`);
}
/**
* Initialize a terminal and connect to WebSocket for streaming
*/
function initTerminal(elementId, taskId) {
const container = document.getElementById(elementId);
if (!container) {
console.error('Terminal container not found:', elementId);
return;
}
const { term, fitAddon } = createTerminal(container);
const ws = createWebSocket(`/ws/terminal/${taskId}`);
ws.onopen = () => {
term.write(`${ANSI.DIM}[Connected]${ANSI.RESET}${ANSI.CRLF}`);
setTerminalLoading(true);
};
ws.onmessage = (event) => term.write(event.data);
ws.onclose = () => setTerminalLoading(false);
ws.onerror = (error) => {
term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
console.error('WebSocket error:', error);
setTerminalLoading(false);
};
terminals[taskId] = { term, ws, fitAddon };
return { term, ws };
}
window.initTerminal = initTerminal;
/**
* Initialize an interactive exec terminal
*/
let execTerminal = null;
let execWs = null;
function initExecTerminal(service, container, host) {
const containerEl = document.getElementById('exec-terminal-container');
const terminalEl = document.getElementById('exec-terminal');
if (!containerEl || !terminalEl) {
console.error('Exec terminal elements not found');
return;
}
containerEl.classList.remove('hidden');
// Clean up existing
if (execWs) { execWs.close(); execWs = null; }
if (execTerminal) { execTerminal.dispose(); execTerminal = null; }
// Create WebSocket first so resize callback can use it
execWs = createWebSocket(`/ws/exec/${service}/${container}/${host}`);
// Resize callback sends size to WebSocket
const sendSize = (cols, rows) => {
if (execWs && execWs.readyState === WebSocket.OPEN) {
execWs.send(JSON.stringify({ type: 'resize', cols, rows }));
}
};
const { term } = createTerminal(terminalEl, { cursorBlink: true }, sendSize);
execTerminal = term;
execWs.onopen = () => { sendSize(term.cols, term.rows); term.focus(); };
execWs.onmessage = (event) => term.write(event.data);
execWs.onclose = () => term.write(`${ANSI.CRLF}${ANSI.DIM}[Connection closed]${ANSI.RESET}${ANSI.CRLF}`);
execWs.onerror = (error) => {
term.write(`${ANSI.RED}[WebSocket Error]${ANSI.RESET}${ANSI.CRLF}`);
console.error('Exec WebSocket error:', error);
};
term.onData((data) => {
if (execWs && execWs.readyState === WebSocket.OPEN) {
execWs.send(data);
}
});
}
window.initExecTerminal = initExecTerminal;
/**
* Refresh dashboard partials while preserving collapse states
*/
function refreshDashboard() {
const isExpanded = (id) => document.getElementById(id)?.checked ?? true;
htmx.ajax('GET', '/partials/sidebar', {target: '#sidebar nav', swap: 'innerHTML'});
htmx.ajax('GET', '/partials/stats', {target: '#stats-cards', swap: 'outerHTML'});
htmx.ajax('GET', `/partials/pending?expanded=${isExpanded('pending-collapse')}`, {target: '#pending-operations', swap: 'outerHTML'});
htmx.ajax('GET', `/partials/services-by-host?expanded=${isExpanded('services-by-host-collapse')}`, {target: '#services-by-host', swap: 'outerHTML'});
htmx.ajax('GET', '/partials/config-error', {target: '#config-error', swap: 'innerHTML'});
}
/**
* Load Monaco editor dynamically (only once)
*/
function loadMonaco(callback) {
if (monacoLoaded) {
callback();
return;
}
if (monacoLoading) {
// Wait for it to load
const checkInterval = setInterval(() => {
if (monacoLoaded) {
clearInterval(checkInterval);
callback();
}
}, 100);
return;
}
monacoLoading = true;
// Load the Monaco loader script
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
script.onload = function() {
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
require(['vs/editor/editor.main'], function() {
monacoLoaded = true;
monacoLoading = false;
callback();
});
};
document.head.appendChild(script);
}
/**
* Create a Monaco editor instance
* @param {HTMLElement} container - Container element
* @param {string} content - Initial content
* @param {string} language - Editor language (yaml, plaintext, etc.)
* @param {boolean} readonly - Whether editor is read-only
* @returns {object} Monaco editor instance
*/
function createEditor(container, content, language, readonly = false) {
const options = {
value: content,
language: language,
theme: 'vs-dark',
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on'
};
if (readonly) {
options.readOnly = true;
options.domReadOnly = true;
}
const editor = monaco.editor.create(container, options);
// Add Command+S / Ctrl+S handler for editable editors
if (!readonly) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function() {
saveAllEditors();
});
}
return editor;
}
/**
* Initialize all Monaco editors on the page
*/
function initMonacoEditors() {
// Dispose existing editors
Object.values(editors).forEach(ed => {
if (ed && ed.dispose) ed.dispose();
});
Object.keys(editors).forEach(key => delete editors[key]);
const editorConfigs = [
{ id: 'compose-editor', language: 'yaml', readonly: false },
{ id: 'env-editor', language: 'plaintext', readonly: false },
{ id: 'config-editor', language: 'yaml', readonly: false },
{ id: 'state-viewer', language: 'yaml', readonly: true }
];
// Check if any editor elements exist
const hasEditors = editorConfigs.some(({ id }) => document.getElementById(id));
if (!hasEditors) return;
// Load Monaco and create editors
loadMonaco(() => {
editorConfigs.forEach(({ id, language, readonly }) => {
const el = document.getElementById(id);
if (!el) return;
const content = el.dataset.content || '';
editors[id] = createEditor(el, content, language, readonly);
if (!readonly) {
editors[id].saveUrl = el.dataset.saveUrl;
}
});
});
}
/**
* Save all editors
*/
async function saveAllEditors() {
const saveBtn = document.getElementById('save-btn') || document.getElementById('save-config-btn');
const results = [];
for (const [id, editor] of Object.entries(editors)) {
if (!editor || !editor.saveUrl) continue;
const content = editor.getValue();
try {
const response = await fetch(editor.saveUrl, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
});
const data = await response.json();
if (!response.ok || !data.success) {
results.push({ id, success: false, error: data.detail || 'Unknown error' });
} else {
results.push({ id, success: true });
}
} catch (e) {
results.push({ id, success: false, error: e.message });
}
}
// Show result
if (saveBtn && results.length > 0) {
saveBtn.textContent = 'Saved!';
setTimeout(() => saveBtn.textContent = saveBtn.id === 'save-config-btn' ? 'Save Config' : 'Save All', 2000);
refreshDashboard();
}
}
/**
* Initialize save button handler
*/
function initSaveButton() {
const saveBtn = document.getElementById('save-btn') || document.getElementById('save-config-btn');
if (!saveBtn) return;
saveBtn.onclick = saveAllEditors;
}
/**
* Global keyboard shortcut handler
*/
function initKeyboardShortcuts() {
document.addEventListener('keydown', function(e) {
// Command+S (Mac) or Ctrl+S (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
// Only handle if we have editors and no Monaco editor is focused
if (Object.keys(editors).length > 0) {
// Check if any Monaco editor is focused
const focusedEditor = Object.values(editors).find(ed => ed && ed.hasTextFocus && ed.hasTextFocus());
if (!focusedEditor) {
e.preventDefault();
saveAllEditors();
}
}
}
});
}
/**
* Initialize page components
*/
function initPage() {
initMonacoEditors();
initSaveButton();
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initPage();
initKeyboardShortcuts();
});
// Re-initialize after HTMX swaps main content
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'main-content') {
initPage();
}
});
/**
* Expand terminal collapse and scroll to it
*/
function expandTerminal() {
const toggle = document.getElementById('terminal-toggle');
if (toggle) toggle.checked = true;
const collapse = document.getElementById('terminal-collapse');
if (collapse) {
collapse.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
/**
* Show/hide terminal loading spinner
*/
function setTerminalLoading(loading) {
const spinner = document.getElementById('terminal-spinner');
if (spinner) {
spinner.classList.toggle('hidden', !loading);
}
}
// Handle action responses (terminal streaming)
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (!evt.detail.successful || !evt.detail.xhr) return;
const text = evt.detail.xhr.responseText;
// Only try to parse if it looks like JSON (starts with {)
if (!text || !text.trim().startsWith('{')) return;
try {
const response = JSON.parse(text);
if (response.task_id) {
// Expand terminal and scroll to it
expandTerminal();
// Wait for xterm to be loaded if needed
const tryInit = (attempts) => {
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
initTerminal('terminal-output', response.task_id);
} else if (attempts > 0) {
setTimeout(() => tryInit(attempts - 1), 100);
} else {
console.error('xterm.js failed to load');
}
};
tryInit(20); // Try for up to 2 seconds
}
} catch (e) {
// Not valid JSON, ignore
}
});
// Command Palette
(function() {
const dialog = document.getElementById('cmd-palette');
const input = document.getElementById('cmd-input');
const list = document.getElementById('cmd-list');
const fab = document.getElementById('cmd-fab');
if (!dialog || !input || !list) return;
const colors = { service: '#22c55e', action: '#eab308', nav: '#3b82f6' };
let commands = [];
let filtered = [];
let selected = 0;
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => window.location.href = url;
const cmd = (type, name, desc, action) => ({ type, name, desc, action });
function buildCommands() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', post('/api/apply')),
cmd('action', 'Refresh', 'Update state from reality', post('/api/refresh')),
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/')),
];
// Add service-specific actions if on a service page
const match = window.location.pathname.match(/^\/service\/(.+)$/);
if (match) {
const svc = decodeURIComponent(match[1]);
const svcCmd = (name, desc, endpoint) => cmd('service', name, `${desc} ${svc}`, post(`/api/service/${svc}/${endpoint}`));
actions.unshift(
svcCmd('Up', 'Start', 'up'),
svcCmd('Down', 'Stop', 'down'),
svcCmd('Restart', 'Restart', 'restart'),
svcCmd('Pull', 'Pull', 'pull'),
svcCmd('Update', 'Pull + restart', 'update'),
svcCmd('Logs', 'View logs for', 'logs'),
);
}
// Add nav commands for all services from sidebar
const services = [...document.querySelectorAll('#sidebar-services li[data-svc] a[href]')].map(a => {
const name = a.getAttribute('href').replace('/service/', '');
return cmd('nav', name, 'Go to service', nav(`/service/${name}`));
});
commands = [...actions, ...services];
}
function filter() {
const q = input.value.toLowerCase();
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
function render() {
list.innerHTML = filtered.map((c, i) => `
<a class="flex justify-between items-center px-3 py-2 rounded-r cursor-pointer hover:bg-base-200 border-l-4 ${i === selected ? 'bg-base-300' : ''}" style="border-left-color: ${colors[c.type] || '#666'}" data-idx="${i}">
<span><span class="opacity-50 text-xs mr-2">${c.type}</span>${c.name}</span>
<span class="opacity-40 text-xs">${c.desc}</span>
</a>
`).join('') || '<div class="opacity-50 p-2">No matches</div>';
}
function open() {
buildCommands();
selected = 0;
input.value = '';
filter();
render();
dialog.showModal();
input.focus();
}
function exec() {
if (filtered[selected]) {
dialog.close();
filtered[selected].action();
}
}
// Keyboard: Cmd+K to open
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
open();
}
});
// Input filtering
input.addEventListener('input', () => { filter(); render(); });
// Keyboard nav inside palette
dialog.addEventListener('keydown', e => {
if (!dialog.open) return;
if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); render(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); }
else if (e.key === 'Enter') { e.preventDefault(); exec(); }
});
// Click to execute
list.addEventListener('click', e => {
const a = e.target.closest('a[data-idx]');
if (a) {
selected = parseInt(a.dataset.idx, 10);
exec();
}
});
// FAB click to open
if (fab) fab.addEventListener('click', open);
})();

View File

@@ -0,0 +1,114 @@
"""Streaming executor adapter for web UI."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from compose_farm.config import Config
# ANSI escape codes for terminal output
RED = "\x1b[31m"
GREEN = "\x1b[32m"
DIM = "\x1b[2m"
RESET = "\x1b[0m"
CRLF = "\r\n"
def _get_ssh_auth_sock() -> str | None:
"""Get SSH_AUTH_SOCK, auto-detecting forwarded agent if needed."""
sock = os.environ.get("SSH_AUTH_SOCK")
if sock and Path(sock).is_socket():
return sock
# Try to find a forwarded SSH agent socket
agent_dir = Path.home() / ".ssh" / "agent"
if agent_dir.is_dir():
sockets = sorted(
agent_dir.glob("s.*.sshd.*"), key=lambda p: p.stat().st_mtime, reverse=True
)
for s in sockets:
if s.is_socket():
return str(s)
return None
# In-memory task registry
tasks: dict[str, dict[str, Any]] = {}
async def stream_to_task(task_id: str, message: str) -> None:
"""Send a message to a task's output buffer."""
if task_id in tasks:
tasks[task_id]["output"].append(message)
async def run_cli_streaming(
config: Config,
args: list[str],
task_id: str,
) -> None:
"""Run a cf CLI command as subprocess and stream output to task buffer.
This reuses all CLI logic including Rich formatting, progress bars, etc.
The subprocess gets a pseudo-TTY via FORCE_COLOR so Rich outputs ANSI codes.
"""
try:
# Build command - config option goes after the subcommand
cmd = ["cf", *args, f"--config={config.config_path}"]
# Show command being executed
cmd_display = " ".join(["cf", *args])
await stream_to_task(task_id, f"{DIM}$ {cmd_display}{RESET}{CRLF}")
# Force color output even though there's no real TTY
# Set COLUMNS for Rich/Typer to format output correctly
env = {"FORCE_COLOR": "1", "TERM": "xterm-256color", "COLUMNS": "120"}
# Ensure SSH agent is available (auto-detect if needed)
ssh_sock = _get_ssh_auth_sock()
if ssh_sock:
env["SSH_AUTH_SOCK"] = ssh_sock
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ, **env},
)
# Stream output line by line
if process.stdout:
async for line in process.stdout:
text = line.decode("utf-8", errors="replace")
# Convert \n to \r\n for xterm.js
if text.endswith("\n") and not text.endswith("\r\n"):
text = text[:-1] + "\r\n"
await stream_to_task(task_id, text)
exit_code = await process.wait()
tasks[task_id]["status"] = "completed" if exit_code == 0 else "failed"
except Exception as e:
await stream_to_task(task_id, f"{RED}Error: {e}{RESET}{CRLF}")
tasks[task_id]["status"] = "failed"
async def run_compose_streaming(
config: Config,
service: str,
command: str,
task_id: str,
) -> None:
"""Run a compose command (up/down/pull/restart) via CLI subprocess."""
# Split command into args (e.g., "up -d" -> ["up", "-d"])
args = command.split()
cli_cmd = args[0] # up, down, pull, restart
extra_args = args[1:] # -d, etc.
# Build CLI args
cli_args = [cli_cmd, service, *extra_args]
await run_cli_streaming(config, cli_args, task_id)

View File

@@ -0,0 +1,67 @@
{% from "partials/icons.html" import github, hamburger %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Compose Farm{% endblock %}</title>
<!-- daisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" data-vendor="daisyui.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" data-vendor="tailwind.js"></script>
<!-- xterm.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" data-vendor="xterm.css">
<!-- Custom styles -->
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="min-h-screen bg-base-200">
<div class="drawer lg:drawer-open">
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
<!-- Main content -->
<div class="drawer-content flex flex-col">
<!-- Mobile navbar with hamburger -->
<header class="navbar bg-base-100 border-b border-base-300 lg:hidden">
<label for="drawer-toggle" class="btn btn-ghost btn-square">
{{ hamburger() }}
</label>
<span class="font-semibold rainbow-hover">Compose Farm</span>
</header>
<main id="main-content" class="flex-1 p-6 overflow-y-auto" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
{% block content %}{% endblock %}
</main>
</div>
<!-- Sidebar -->
<div class="drawer-side">
<label for="drawer-toggle" class="drawer-overlay" aria-label="close sidebar"></label>
<aside id="sidebar" class="w-64 bg-base-100 border-r border-base-300 flex flex-col min-h-screen">
<header class="p-4 border-b border-base-300">
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="rainbow-hover">Compose Farm</span>
<a href="https://github.com/basnijholt/compose-farm" target="_blank" title="GitHub" class="opacity-50 hover:opacity-100 transition-opacity">
{{ github() }}
</a>
</h2>
</header>
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load" hx-swap="innerHTML">
<span class="loading loading-spinner loading-sm"></span> Loading...
</nav>
</aside>
</div>
</div>
<!-- Command Palette -->
{% include "partials/command_palette.html" %}
<!-- Scripts - HTMX first -->
<script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,73 @@
{% 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 %}
{% block title %}Dashboard - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-5xl">
{{ page_header("Compose Farm", "Cluster overview and management") }}
<!-- Stats Cards -->
{% include "partials/stats.html" %}
<!-- Global Actions -->
<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>
</div>
{% include "partials/terminal.html" %}
<!-- Config Error Banner -->
<div id="config-error">
{% if config_error %}
{% include "partials/config_error.html" %}
{% endif %}
</div>
<!-- Config Editor -->
{% call collapse("Edit Config", badge="compose-farm.yaml", icon=settings(), checked=config_error) %}
<div class="editor-wrapper yaml-wrapper">
<div id="config-editor" class="yaml-editor" data-content="{{ config_content | e }}" data-save-url="/api/config"></div>
</div>
{% endcall %}
<!-- Pending Operations -->
{% include "partials/pending.html" %}
<!-- Services by Host -->
{% include "partials/services_by_host.html" %}
<!-- Hosts Configuration -->
{% call collapse("Hosts (" ~ (hosts | length) ~ ")", icon=server()) %}
{% call table() %}
<thead>
<tr>
<th>Name</th>
<th>Address</th>
<th>User</th>
<th>Port</th>
</tr>
</thead>
<tbody>
{% for name, host in hosts.items() %}
<tr class="hover:bg-base-300">
<td class="font-semibold">{{ name }}</td>
<td><code class="text-sm">{{ host.address }}</code></td>
<td><code class="text-sm">{{ host.user }}</code></td>
<td><code class="text-sm">{{ host.port }}</code></td>
</tr>
{% endfor %}
</tbody>
{% endcall %}
{% endcall %}
<!-- State Viewer -->
{% call collapse("Raw State", badge="compose-farm-state.yaml", icon=database()) %}
<div class="editor-wrapper viewer-wrapper">
<div id="state-viewer" class="yaml-viewer" data-content="{{ state_content | e }}"></div>
</div>
{% endcall %}
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% from "partials/icons.html" import search, command %}
<dialog id="cmd-palette" class="modal">
<div class="modal-box max-w-lg p-0">
<label class="input input-lg bg-base-100 border-0 border-b border-base-300 w-full rounded-none rounded-t-box sticky top-0 z-10 focus-within:outline-none">
{{ search(20) }}
<input type="text" id="cmd-input" class="grow" placeholder="Type a command..." autocomplete="off" />
<kbd class="kbd kbd-sm opacity-50">esc</kbd>
</label>
<div id="cmd-list" class="flex flex-col p-2 max-h-80 overflow-y-auto">
<!-- Populated by JS -->
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Floating button to open command palette -->
<button id="cmd-fab" class="btn btn-circle glass shadow-lg fixed bottom-6 right-6 z-50 hover:ring hover:ring-base-content/50" title="Command Palette (⌘K)">
{{ command(24) }}
</button>

View File

@@ -0,0 +1,52 @@
{# Page header with title and optional subtitle (supports HTML) #}
{% macro page_header(title, subtitle=None) %}
<div class="mb-6">
<h1 class="text-3xl font-bold rainbow-hover">{{ title }}</h1>
{% if subtitle %}
<p class="text-base-content/60 text-lg">{{ subtitle | safe }}</p>
{% endif %}
</div>
{% endmacro %}
{# Collapsible section #}
{% macro collapse(title, id=None, checked=False, badge=None, icon=None) %}
<div class="collapse collapse-arrow bg-base-100 shadow mb-4">
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} {% if checked %}checked{% endif %} />
<div class="collapse-title font-medium flex items-center gap-2">
{% if icon %}{{ icon }}{% endif %}{{ title }}
{% if badge %}<code class="text-xs ml-2 opacity-60">{{ badge }}</code>{% endif %}
</div>
<div class="collapse-content">
{{ caller() }}
</div>
</div>
{% endmacro %}
{# Action button with htmx #}
{% macro action_btn(label, url, style="outline", title=None, icon=None) %}
<button hx-post="{{ url }}"
hx-swap="none"
class="btn btn-{{ style }}"
{% if title %}title="{{ title }}"{% endif %}>
{% if icon %}{{ icon }}{% endif %}{{ label }}
</button>
{% endmacro %}
{# Stat card for dashboard #}
{% macro stat_card(label, value, color=None, icon=None) %}
<div class="card bg-base-100 shadow">
<div class="card-body items-center text-center">
<h2 class="card-title text-base-content/60 text-sm gap-1">{% if icon %}{{ icon }}{% endif %}{{ label }}</h2>
<p class="text-4xl font-bold {% if color %}text-{{ color }}{% endif %}">{{ value }}</p>
</div>
</div>
{% endmacro %}
{# Data table wrapper #}
{% macro table() %}
<div class="overflow-x-auto">
<table class="table table-zebra">
{{ caller() }}
</table>
</div>
{% endmacro %}

View File

@@ -0,0 +1,8 @@
{% from "partials/icons.html" import alert_triangle %}
<div class="alert alert-error mb-4">
{{ alert_triangle(size=20) }}
<div>
<h3 class="font-bold">Configuration Error</h3>
<div class="text-sm">{{ config_error }}</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
{# Container list for a service on a single host #}
{% from "partials/icons.html" import terminal %}
{% macro container_row(service, 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>
{% else %}
<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('{{ service }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
</div>
{% endmacro %}
{% macro host_containers(service, host, containers, show_header=False) %}
{% if show_header %}
<div class="font-semibold text-sm mt-3 mb-1">{{ host }}</div>
{% endif %}
{% for container in containers %}
{{ container_row(service, container, host) }}
{% endfor %}
{% endmacro %}

View File

@@ -0,0 +1,148 @@
{# Lucide-style icons (https://lucide.dev) - 24x24 viewBox, 2px stroke, round caps #}
{# Brand icons #}
{% macro github(size=16) %}
<svg height="{{ size }}" width="{{ size }}" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
{% endmacro %}
{# UI icons #}
{% macro hamburger(size=20) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/>
</svg>
{% endmacro %}
{% macro command(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>
</svg>
{% endmacro %}
{# Action icons #}
{% macro play(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="6 3 20 12 6 21 6 3"/>
</svg>
{% endmacro %}
{% macro square(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="5" y="5" rx="2"/>
</svg>
{% endmacro %}
{% macro rotate_cw(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>
</svg>
{% endmacro %}
{% macro download(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>
</svg>
{% endmacro %}
{% macro cloud_download(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8l-4-4"/><path d="m12 21 4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/>
</svg>
{% endmacro %}
{% macro file_text(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>
</svg>
{% endmacro %}
{% macro save(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>
</svg>
{% endmacro %}
{% macro check(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
{% endmacro %}
{% macro refresh_cw(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>
</svg>
{% endmacro %}
{% macro terminal(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>
</svg>
{% endmacro %}
{# Stats/navigation icons #}
{% macro server(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>
</svg>
{% endmacro %}
{% macro layers(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>
</svg>
{% endmacro %}
{% macro circle_check(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>
</svg>
{% endmacro %}
{% macro circle_x(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>
</svg>
{% endmacro %}
{% macro home(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
{% endmacro %}
{% macro box(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
{% endmacro %}
{# Section icons #}
{% macro settings(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
{% endmacro %}
{% macro file_code(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 12.5 8 15l2 2.5"/><path d="m14 12.5 2 2.5-2 2.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/>
</svg>
{% endmacro %}
{% macro database(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>
</svg>
{% endmacro %}
{% macro search(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
{% endmacro %}
{% macro alert_triangle(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>
</svg>
{% endmacro %}

View File

@@ -0,0 +1,38 @@
{% from "partials/components.html" import collapse %}
<div id="pending-operations">
{% if orphaned or migrations or not_started %}
{% call collapse("Pending Operations", id="pending-collapse", checked=expanded|default(true)) %}
{% if orphaned %}
<h4 class="font-semibold mt-2 mb-1">Orphaned Services (will be stopped)</h4>
<ul class="list-disc list-inside mb-4">
{% for svc, host in orphaned.items() %}
<li><a href="/service/{{ svc }}" class="badge badge-warning hover:badge-primary">{{ svc }}</a> on {{ host }}</li>
{% endfor %}
</ul>
{% endif %}
{% if migrations %}
<h4 class="font-semibold mt-2 mb-1">Services Needing Migration</h4>
<ul class="list-disc list-inside mb-4">
{% for svc in migrations %}
<li><a href="/service/{{ svc }}" class="badge badge-info hover:badge-primary">{{ svc }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if not_started %}
<h4 class="font-semibold mt-2 mb-1">Services Not Started</h4>
<ul class="menu menu-horizontal bg-base-200 rounded-box mb-2">
{% for svc in not_started | sort %}
<li><a href="/service/{{ svc }}">{{ svc }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endcall %}
{% else %}
<div role="alert" class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>All services are in sync with configuration.</span>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,41 @@
{% from "partials/components.html" import collapse %}
{% from "partials/icons.html" import layers, search %}
<div id="services-by-host">
{% call collapse("Services by Host", id="services-by-host-collapse", checked=expanded|default(true), icon=layers()) %}
<div class="flex flex-wrap gap-2 mb-4 items-center">
<label class="input input-sm input-bordered flex items-center gap-2 bg-base-200">
{{ search() }}<input type="text" id="sbh-filter" class="w-32" placeholder="Filter..." onkeyup="sbhFilter()" />
</label>
<select id="sbh-host-select" class="select select-sm select-bordered bg-base-200" onchange="sbhFilter()">
<option value="">All hosts</option>
{% for h in services_by_host.keys() | sort %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
</select>
</div>
{% for host_name, host_services in services_by_host.items() | sort %}
<div class="sbh-group" data-h="{{ host_name }}">
<h4 class="font-semibold mt-3 mb-1">{{ host_name }}{% if host_name in hosts %}<code class="text-xs ml-2 opacity-60">{{ hosts[host_name].address }}</code>{% endif %}</h4>
<ul class="menu menu-horizontal bg-base-200 rounded-box mb-2 flex-wrap">
{% for svc in host_services | sort %}<li data-s="{{ svc | lower }}"><a href="/service/{{ svc }}">{{ svc }}</a></li>{% endfor %}
</ul>
</div>
{% else %}
<p class="text-base-content/60 italic">No services currently running.</p>
{% endfor %}
<script>
function sbhFilter() {
const q = (document.getElementById('sbh-filter')?.value || '').toLowerCase();
const h = document.getElementById('sbh-host-select')?.value || '';
document.querySelectorAll('.sbh-group').forEach(g => {
if (h && g.dataset.h !== h) { g.hidden = true; return; }
let n = 0;
g.querySelectorAll('li[data-s]').forEach(li => {
const show = !q || li.dataset.s.includes(q);
li.hidden = !show;
if (show) n++;
});
g.hidden = !n;
});
}
</script>
{% endcall %}
</div>

View File

@@ -0,0 +1,45 @@
{% from "partials/icons.html" import home, search %}
<!-- Dashboard Link -->
<div class="mb-4">
<ul class="menu" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
<li><a href="/" class="font-semibold">{{ home() }} Dashboard</a></li>
</ul>
</div>
<!-- Services Section -->
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-base-content/60 px-3 py-1">Services <span class="opacity-50" id="sidebar-count">({{ services | length }})</span></h4>
<div class="px-2 mb-2 flex flex-col gap-1">
<label class="input input-xs input-bordered flex items-center gap-2 bg-base-200">
{{ search(14) }}<input type="text" id="sidebar-filter" placeholder="Filter..." onkeyup="sidebarFilter()" />
</label>
<select id="sidebar-host-select" class="select select-xs select-bordered bg-base-200 w-full" onchange="sidebarFilter()">
<option value="">All hosts</option>
{% for h in hosts %}<option value="{{ h }}">{{ h }}</option>{% endfor %}
</select>
</div>
<ul class="menu menu-sm" id="sidebar-services" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
{% for service in services %}
<li data-svc="{{ service | lower }}" data-h="{{ service_hosts.get(service, '') }}">
<a href="/service/{{ service }}" class="flex items-center gap-2">
{% if service in state %}<span class="status status-success" title="In state file"></span>
{% else %}<span class="status status-neutral" title="Not in state file"></span>{% endif %}
{{ service }}
</a>
</li>
{% endfor %}
</ul>
</div>
<script>
function sidebarFilter() {
const q = (document.getElementById('sidebar-filter')?.value || '').toLowerCase();
const h = document.getElementById('sidebar-host-select')?.value || '';
let n = 0;
document.querySelectorAll('#sidebar-services li').forEach(li => {
const show = (!q || li.dataset.svc.includes(q)) && (!h || !li.dataset.h || li.dataset.h === h);
li.hidden = !show;
if (show) n++;
});
document.getElementById('sidebar-count').textContent = '(' + n + ')';
}
</script>

View File

@@ -0,0 +1,8 @@
{% from "partials/components.html" import stat_card %}
{% from "partials/icons.html" import server, layers, circle_check, circle_x %}
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{{ stat_card("Hosts", hosts | length, icon=server()) }}
{{ stat_card("Services", services | length, icon=layers()) }}
{{ stat_card("Running", running_count, "success", circle_check()) }}
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
</div>

View File

@@ -0,0 +1,14 @@
{% from "partials/icons.html" import terminal %}
<!-- Shared Terminal Component -->
<div class="collapse collapse-arrow bg-base-100 shadow mb-4" id="terminal-collapse">
<input type="checkbox" id="terminal-toggle" />
<div class="collapse-title font-medium flex items-center gap-2">
{{ terminal() }} Terminal Output
<span id="terminal-spinner" class="loading loading-spinner loading-sm hidden"></span>
</div>
<div class="collapse-content">
<div id="terminal-container" class="bg-[#1a1a2e] rounded-lg h-[300px] border border-white/10 resize-y overflow-hidden">
<div id="terminal-output" class="h-full"></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% from "partials/components.html" import collapse, action_btn %}
{% from "partials/icons.html" import play, square, rotate_cw, download, cloud_download, file_text, save, file_code, terminal, settings %}
{% block title %}{{ name }} - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-5xl">
<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">
{% if current_host %}
<span class="badge badge-success">Running on {{ current_host }}</span>
{% else %}
<span class="badge badge-neutral">Not running</span>
{% endif %}
<span class="badge badge-outline">{{ hosts | join(', ') }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-2 mb-6">
<!-- Lifecycle -->
{{ action_btn("Up", "/api/service/" ~ name ~ "/up", "primary", "Start service (docker compose up -d)", play()) }}
{{ action_btn("Down", "/api/service/" ~ name ~ "/down", "outline", "Stop service (docker compose down)", square()) }}
{{ action_btn("Restart", "/api/service/" ~ name ~ "/restart", "secondary", "Restart service (down + up)", rotate_cw()) }}
{{ action_btn("Update", "/api/service/" ~ name ~ "/update", "accent", "Update to latest (pull + build + down + up)", download()) }}
<div class="divider divider-horizontal mx-0"></div>
<!-- Other -->
{{ action_btn("Pull", "/api/service/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
{{ action_btn("Logs", "/api/service/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
<button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button>
</div>
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}
<div class="editor-wrapper yaml-wrapper">
<div id="compose-editor" class="yaml-editor" data-content="{{ compose_content | e }}" data-save-url="/api/service/{{ name }}/compose"></div>
</div>
{% endcall %}
{% call collapse(".env File", badge=env_path, icon=settings()) %}
<div class="editor-wrapper env-wrapper">
<div id="env-editor" class="env-editor" data-content="{{ env_content | e }}" data-save-url="/api/service/{{ name }}/env"></div>
</div>
{% endcall %}
{% include "partials/terminal.html" %}
<!-- Exec Terminal -->
{% if current_host %}
{% call collapse("Container Shell", id="exec-collapse", checked=True, icon=terminal()) %}
<div id="containers-list" class="mb-4"
hx-get="/api/service/{{ name }}/containers"
hx-trigger="load"
hx-target="this"
hx-select="unset"
hx-swap="innerHTML">
<span class="loading loading-spinner loading-sm"></span> Loading containers...
</div>
<div id="exec-terminal-container" class="bg-[#1a1a2e] rounded-lg h-[400px] border border-white/10 hidden">
<div id="exec-terminal" class="h-full"></div>
</div>
{% endcall %}
{% endif %}
</div>
{% endblock %}

236
src/compose_farm/web/ws.py Normal file
View File

@@ -0,0 +1,236 @@
"""WebSocket handler for terminal streaming."""
from __future__ import annotations
import asyncio
import contextlib
import fcntl
import json
import os
import pty
import signal
import struct
import termios
from typing import TYPE_CHECKING, Any
import asyncssh
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from compose_farm.executor import is_local
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks
if TYPE_CHECKING:
from compose_farm.config import Host
router = APIRouter()
def _parse_resize(msg: str) -> tuple[int, int] | None:
"""Parse a resize message, return (cols, rows) or None if not a resize."""
try:
data = json.loads(msg)
if data.get("type") == "resize":
return int(data["cols"]), int(data["rows"])
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
pass
return None
def _resize_pty(
fd: int, cols: int, rows: int, proc: asyncio.subprocess.Process | None = None
) -> None:
"""Resize a local PTY and send SIGWINCH to the process."""
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
# Explicitly send SIGWINCH so docker exec forwards it to the container
if proc and proc.pid:
os.kill(proc.pid, signal.SIGWINCH)
async def _bridge_websocket_to_fd(
websocket: WebSocket,
master_fd: int,
proc: asyncio.subprocess.Process,
) -> None:
"""Bridge WebSocket to a local PTY file descriptor."""
loop = asyncio.get_event_loop()
async def read_output() -> None:
while proc.returncode is None:
try:
data = await loop.run_in_executor(None, lambda: os.read(master_fd, 4096))
except BlockingIOError:
await asyncio.sleep(0.01)
continue
except OSError:
break
if not data:
break
await websocket.send_text(data.decode("utf-8", errors="replace"))
read_task = asyncio.create_task(read_output())
try:
while proc.returncode is None:
try:
msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
except TimeoutError:
continue
if size := _parse_resize(msg):
_resize_pty(master_fd, *size, proc)
else:
os.write(master_fd, msg.encode())
finally:
read_task.cancel()
os.close(master_fd)
if proc.returncode is None:
proc.terminate()
async def _bridge_websocket_to_ssh(
websocket: WebSocket,
proc: Any, # asyncssh.SSHClientProcess
) -> None:
"""Bridge WebSocket to an SSH process with PTY."""
assert proc.stdout is not None
assert proc.stdin is not None
async def read_stdout() -> None:
while proc.returncode is None:
data = await proc.stdout.read(4096)
if not data:
break
text = data if isinstance(data, str) else data.decode()
await websocket.send_text(text)
read_task = asyncio.create_task(read_stdout())
try:
while proc.returncode is None:
try:
msg = await asyncio.wait_for(websocket.receive_text(), timeout=0.1)
except TimeoutError:
continue
if size := _parse_resize(msg):
proc.change_terminal_size(*size)
else:
proc.stdin.write(msg)
finally:
read_task.cancel()
proc.terminate()
async def _run_local_exec(websocket: WebSocket, exec_cmd: str) -> None:
"""Run docker exec locally with PTY."""
master_fd, slave_fd = pty.openpty()
proc = await asyncio.create_subprocess_shell(
exec_cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
os.close(slave_fd)
# Set non-blocking
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
await _bridge_websocket_to_fd(websocket, master_fd, proc)
async def _run_remote_exec(websocket: WebSocket, host: Host, exec_cmd: str) -> None:
"""Run docker exec on remote host via SSH with PTY."""
async with asyncssh.connect(
host.address,
port=host.port,
username=host.user,
known_hosts=None,
) as conn:
proc: asyncssh.SSHClientProcess[Any] = await conn.create_process(
exec_cmd,
term_type="xterm-256color",
term_size=(80, 24),
)
async with proc:
await _bridge_websocket_to_ssh(websocket, proc)
async def _run_exec_session(
websocket: WebSocket,
container: str,
host_name: str,
) -> None:
"""Run an interactive docker exec session over WebSocket."""
config = get_config()
host = config.hosts.get(host_name)
if not host:
await websocket.send_text(f"{RED}Host '{host_name}' not found{RESET}{CRLF}")
return
exec_cmd = f"docker exec -it {container} /bin/sh -c 'command -v bash >/dev/null && exec bash || exec sh'"
if is_local(host):
await _run_local_exec(websocket, exec_cmd)
else:
await _run_remote_exec(websocket, host, exec_cmd)
@router.websocket("/ws/exec/{service}/{container}/{host}")
async def exec_websocket(
websocket: WebSocket,
service: str, # noqa: ARG001
container: str,
host: str,
) -> None:
"""WebSocket endpoint for interactive container exec."""
await websocket.accept()
try:
await websocket.send_text(f"{DIM}[Connecting to {container} on {host}...]{RESET}{CRLF}")
await _run_exec_session(websocket, container, host)
await websocket.send_text(f"{CRLF}{DIM}[Disconnected]{RESET}{CRLF}")
except WebSocketDisconnect:
pass
except Exception as e:
with contextlib.suppress(Exception):
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
finally:
with contextlib.suppress(Exception):
await websocket.close()
@router.websocket("/ws/terminal/{task_id}")
async def terminal_websocket(websocket: WebSocket, task_id: str) -> None:
"""WebSocket endpoint for terminal streaming."""
await websocket.accept()
if task_id not in tasks:
await websocket.send_text(f"{RED}Error: Task not found{RESET}{CRLF}")
await websocket.close(code=4004)
return
task = tasks[task_id]
sent_count = 0
try:
while True:
# Send any new output
while sent_count < len(task["output"]):
await websocket.send_text(task["output"][sent_count])
sent_count += 1
if task["status"] in ("completed", "failed"):
status = "[Done]" if task["status"] == "completed" else "[Failed]"
color = GREEN if task["status"] == "completed" else RED
await websocket.send_text(f"{CRLF}{color}{status}{RESET}{CRLF}")
await websocket.close()
break
await asyncio.sleep(0.05)
except WebSocketDisconnect:
pass
finally:
tasks.pop(task_id, None)

58
tests/test_cli_startup.py Normal file
View File

@@ -0,0 +1,58 @@
"""Test CLI startup performance."""
from __future__ import annotations
import shutil
import subprocess
import sys
import time
# Thresholds in seconds, per OS
if sys.platform == "win32":
CLI_STARTUP_THRESHOLD = 2.0
elif sys.platform == "darwin":
CLI_STARTUP_THRESHOLD = 0.35
else: # Linux
CLI_STARTUP_THRESHOLD = 0.25
def test_cli_startup_time() -> None:
"""Verify CLI startup time stays within acceptable bounds.
This test ensures we don't accidentally introduce slow imports
that degrade the user experience.
"""
cf_path = shutil.which("cf")
assert cf_path is not None, "cf command not found in PATH"
# Run up to 6 times, return early if we hit the threshold
times: list[float] = []
for _ in range(6):
start = time.perf_counter()
result = subprocess.run(
[cf_path, "--help"],
check=False,
capture_output=True,
text=True,
)
elapsed = time.perf_counter() - start
times.append(elapsed)
# Verify the command succeeded
assert result.returncode == 0, f"CLI failed: {result.stderr}"
# Pass early if under threshold
if elapsed < CLI_STARTUP_THRESHOLD:
print(f"\nCLI startup: {elapsed:.3f}s (threshold: {CLI_STARTUP_THRESHOLD}s)")
return
# All attempts exceeded threshold
best_time = min(times)
msg = (
f"\nCLI startup times: {[f'{t:.3f}s' for t in times]}\n"
f"Best: {best_time:.3f}s, Threshold: {CLI_STARTUP_THRESHOLD}s"
)
print(msg)
err_msg = f"CLI startup too slow!\n{msg}\nCheck for slow imports."
raise AssertionError(err_msg)

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import inspect
from pathlib import Path # noqa: TC003
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -55,12 +55,12 @@ class TestMigrationCommands:
commands_called: list[str] = []
async def mock_run_compose_step(
cfg: Config, # noqa: ARG001
cfg: Config,
service: str,
command: str,
*,
raw: bool, # noqa: ARG001
host: str | None = None, # noqa: ARG001
raw: bool,
host: str | None = None,
) -> CommandResult:
commands_called.append(command)
return CommandResult(

1
tests/web/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Web UI tests."""

87
tests/web/conftest.py Normal file
View File

@@ -0,0 +1,87 @@
"""Fixtures for web UI tests."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from compose_farm.config import Config
@pytest.fixture
def compose_dir(tmp_path: Path) -> Path:
"""Create a temporary compose directory with sample services."""
compose_path = tmp_path / "compose"
compose_path.mkdir()
# Create a sample service
plex_dir = compose_path / "plex"
plex_dir.mkdir()
(plex_dir / "compose.yaml").write_text("""
services:
plex:
image: plexinc/pms-docker
container_name: plex
ports:
- "32400:32400"
""")
(plex_dir / ".env").write_text("PLEX_CLAIM=claim-xxx\n")
# Create another service
sonarr_dir = compose_path / "sonarr"
sonarr_dir.mkdir()
(sonarr_dir / "compose.yaml").write_text("""
services:
sonarr:
image: linuxserver/sonarr
""")
return compose_path
@pytest.fixture
def config_file(tmp_path: Path, compose_dir: Path) -> Path:
"""Create a temporary config file and state file."""
config_path = tmp_path / "compose-farm.yaml"
config_path.write_text(f"""
compose_dir: {compose_dir}
hosts:
server-1:
address: 192.168.1.10
user: docker
server-2:
address: 192.168.1.11
services:
plex: server-1
sonarr: server-2
""")
# State file must be alongside config file
state_path = tmp_path / "compose-farm-state.yaml"
state_path.write_text("""
deployed:
plex: server-1
""")
return config_path
@pytest.fixture
def mock_config(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
"""Patch get_config to return a test config."""
from compose_farm.config import load_config
from compose_farm.web import deps as web_deps
from compose_farm.web.routes import api as web_api
config = load_config(config_file)
# Patch in all modules that import get_config
monkeypatch.setattr(web_deps, "get_config", lambda: config)
monkeypatch.setattr(web_api, "get_config", lambda: config)
return config

106
tests/web/test_helpers.py Normal file
View File

@@ -0,0 +1,106 @@
"""Tests for web API helper functions."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from fastapi import HTTPException
if TYPE_CHECKING:
from compose_farm.config import Config
class TestValidateYaml:
"""Tests for _validate_yaml helper."""
def test_valid_yaml(self) -> None:
from compose_farm.web.routes.api import _validate_yaml
# Should not raise
_validate_yaml("key: value")
_validate_yaml("list:\n - item1\n - item2")
_validate_yaml("")
def test_invalid_yaml(self) -> None:
from compose_farm.web.routes.api import _validate_yaml
with pytest.raises(HTTPException) as exc_info:
_validate_yaml("key: [unclosed")
assert exc_info.value.status_code == 400
assert "Invalid YAML" in exc_info.value.detail
class TestGetServiceComposePath:
"""Tests for _get_service_compose_path helper."""
def test_service_found(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _get_service_compose_path
path = _get_service_compose_path("plex")
assert isinstance(path, Path)
assert path.name == "compose.yaml"
assert path.parent.name == "plex"
def test_service_not_found(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _get_service_compose_path
with pytest.raises(HTTPException) as exc_info:
_get_service_compose_path("nonexistent")
assert exc_info.value.status_code == 404
assert "not found" in exc_info.value.detail
class TestRenderContainers:
"""Tests for container template rendering."""
def test_render_running_container(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "running"}]
html = _render_containers("plex", "server-1", containers)
assert "badge-success" in html
assert "plex" in html
assert "initExecTerminal" in html
def test_render_unknown_state(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "unknown"}]
html = _render_containers("plex", "server-1", containers)
assert "loading-spinner" in html
def test_render_other_state(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "exited"}]
html = _render_containers("plex", "server-1", containers)
assert "badge-warning" in html
assert "exited" in html
def test_render_with_header(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [{"Name": "plex", "State": "running"}]
html = _render_containers("plex", "server-1", containers, show_header=True)
assert "server-1" in html
assert "font-semibold" in html
def test_render_multiple_containers(self, mock_config: Config) -> None:
from compose_farm.web.routes.api import _render_containers
containers = [
{"Name": "app-web-1", "State": "running"},
{"Name": "app-db-1", "State": "running"},
]
html = _render_containers("app", "server-1", containers)
assert "app-web-1" in html
assert "app-db-1" in html

987
uv.lock generated

File diff suppressed because it is too large Load Diff