From de46c3ff0f0b8acae45ce6683fcad38d8fec54d8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 20 Dec 2025 15:00:03 -0800 Subject: [PATCH] feat: add web UI demo recording system (#69) --- .pre-commit-config.yaml | 1 + README.md | 55 +----- docs/architecture.md | 6 +- docs/assets/web-console.gif | 3 + docs/assets/web-console.webm | 3 + docs/assets/web-navigation.gif | 3 + docs/assets/web-navigation.webm | 3 + docs/assets/web-service.gif | 3 + docs/assets/web-service.webm | 3 + docs/assets/web-shell.gif | 3 + docs/assets/web-shell.webm | 3 + docs/assets/web-themes.gif | 3 + docs/assets/web-themes.webm | 3 + docs/assets/web-workflow.gif | 3 + docs/assets/web-workflow.webm | 3 + docs/best-practices.md | 13 +- docs/commands.md | 2 +- docs/configuration.md | 23 +-- docs/demos/README.md | 33 ++-- docs/demos/cli/README.md | 32 ++++ docs/demos/{ => cli}/apply.tape | 0 docs/demos/{ => cli}/install.tape | 0 docs/demos/{ => cli}/logs.tape | 0 docs/demos/{ => cli}/migration.tape | 0 docs/demos/{ => cli}/quickstart.tape | 0 docs/demos/{ => cli}/record.sh | 3 +- docs/demos/{ => cli}/update.tape | 2 +- docs/demos/web/README.md | 45 +++++ docs/demos/web/__init__.py | 1 + docs/demos/web/conftest.py | 220 ++++++++++++++++++++++ docs/demos/web/demo_console.py | 73 ++++++++ docs/demos/web/demo_navigation.py | 74 ++++++++ docs/demos/web/demo_service.py | 96 ++++++++++ docs/demos/web/demo_shell.py | 71 ++++++++ docs/demos/web/demo_themes.py | 81 +++++++++ docs/demos/web/demo_workflow.py | 97 ++++++++++ docs/demos/web/record.py | 263 +++++++++++++++++++++++++++ docs/getting-started.md | 44 +---- docs/index.md | 46 +---- docs/traefik.md | 2 - docs/web-ui.md | 130 +++++++++++++ pyproject.toml | 4 + src/compose_farm/web/cdn.py | 108 +++++++++++ tests/web/test_htmx_browser.py | 85 +-------- 44 files changed, 1389 insertions(+), 257 deletions(-) create mode 100644 docs/assets/web-console.gif create mode 100644 docs/assets/web-console.webm create mode 100644 docs/assets/web-navigation.gif create mode 100644 docs/assets/web-navigation.webm create mode 100644 docs/assets/web-service.gif create mode 100644 docs/assets/web-service.webm create mode 100644 docs/assets/web-shell.gif create mode 100644 docs/assets/web-shell.webm create mode 100644 docs/assets/web-themes.gif create mode 100644 docs/assets/web-themes.webm create mode 100644 docs/assets/web-workflow.gif create mode 100644 docs/assets/web-workflow.webm create mode 100644 docs/demos/cli/README.md rename docs/demos/{ => cli}/apply.tape (100%) rename docs/demos/{ => cli}/install.tape (100%) rename docs/demos/{ => cli}/logs.tape (100%) rename docs/demos/{ => cli}/migration.tape (100%) rename docs/demos/{ => cli}/quickstart.tape (100%) rename docs/demos/{ => cli}/record.sh (97%) rename docs/demos/{ => cli}/update.tape (90%) create mode 100644 docs/demos/web/README.md create mode 100644 docs/demos/web/__init__.py create mode 100644 docs/demos/web/conftest.py create mode 100644 docs/demos/web/demo_console.py create mode 100644 docs/demos/web/demo_navigation.py create mode 100644 docs/demos/web/demo_service.py create mode 100644 docs/demos/web/demo_shell.py create mode 100644 docs/demos/web/demo_themes.py create mode 100644 docs/demos/web/demo_workflow.py create mode 100755 docs/demos/web/record.py create mode 100644 docs/web-ui.md create mode 100644 src/compose_farm/web/cdn.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60bce3d..b6d4405 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,7 @@ repos: rev: v1.14.0 hooks: - id: mypy + exclude: ^docs/demos/ additional_dependencies: - pydantic>=2.0.0 - typer>=0.9.0 diff --git a/README.md b/README.md index 5c65650..e19a6bf 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,12 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH. > [!NOTE] > Run `docker compose` commands across multiple hosts via SSH. One YAML maps services to hosts. Run `cf apply` and reality matches your config—services start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic. -Single host? Map everything to `localhost` and Compose Farm just runs locally from each stack folder. - - [Why Compose Farm?](#why-compose-farm) - [How It Works](#how-it-works) - [Requirements](#requirements) - - [Single host (local-only)](#single-host-local-only) - - [Multi-host](#multi-host) - [Limitations & Best Practices](#limitations--best-practices) - [What breaks when you move a service](#what-breaks-when-you-move-a-service) - [Best practices](#best-practices) @@ -31,8 +27,6 @@ Single host? Map everything to `localhost` and Compose Farm just runs locally fr - [SSH Agent (default)](#ssh-agent-default) - [Dedicated SSH Key (recommended for Docker/Web UI)](#dedicated-ssh-key-recommended-for-dockerweb-ui) - [Configuration](#configuration) - - [Single-host example](#single-host-example) - - [Multi-host example](#multi-host-example) - [Multi-Host Services](#multi-host-services) - [Config Command](#config-command) - [Usage](#usage) @@ -91,24 +85,13 @@ That's it. No orchestration, no service discovery, no magic. ## Requirements -### Single host (local-only) - - Python 3.11+ (we recommend [uv](https://docs.astral.sh/uv/) for installation) -- Docker and Docker Compose installed -- One folder per stack under `compose_dir` - -If you're on a single host, you can skip SSH, shared storage, and Traefik. Compose Farm just runs `docker compose` locally in each stack folder. - -### Multi-host - -- Everything above, plus: +- SSH key-based authentication to your hosts (uses ssh-agent) - Docker and Docker Compose installed on all target hosts -- SSH key-based authentication to your hosts (ssh-agent or `cf ssh setup` dedicated key) -- **Shared storage or synced folders** so `compose_dir` is the same path on all hosts (NFS recommended) +- **Shared storage**: All compose files must be accessible at the same path on all hosts - **Docker networks**: External networks must exist on all hosts (use `cf init-network` to create) -- **Optional for ingress**: Traefik file provider for cross-host routing (labels + published ports, generated via `cf traefik-file`) -For multi-host setups, Compose Farm assumes your compose files are accessible at the same path on all hosts. This is typically achieved via: +Compose Farm assumes your compose files are accessible at the same path on all hosts. This is typically achieved via: - **NFS mount** (e.g., `/opt/compose` mounted from a NAS) - **Synced folders** (e.g., Syncthing, rsync) @@ -242,24 +225,6 @@ The keys will persist across restarts. Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory): -### Single-host example - -No SSH, shared storage, or Traefik file-provider required. - -```yaml -compose_dir: /opt/stacks - -hosts: - local: localhost # Run locally without SSH - -services: - plex: local - jellyfin: local - traefik: local -``` - -### Multi-host example - ```yaml compose_dir: /opt/compose # Must be the same path on all hosts @@ -270,20 +235,20 @@ hosts: server-2: address: 192.168.1.11 # user defaults to current user + local: localhost # Run locally without SSH services: plex: server-1 jellyfin: server-2 sonarr: server-1 + radarr: local # Runs on the machine where you invoke compose-farm # Multi-host services (run on multiple/all hosts) autokuma: all # Runs on ALL configured hosts dozzle: [server-1, server-2] # Explicit list of hosts ``` -For cross-host HTTP routing, add Traefik labels to your compose files and set `traefik_file` so Compose Farm can generate the file-provider config. - -Each entry in `services:` maps to a folder under `compose_dir` that contains a compose file. Compose files are expected at `{compose_dir}/{service}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`). +Compose files are expected at `{compose_dir}/{service}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`). ### Multi-Host Services @@ -338,7 +303,7 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias. | `cf up ` | Start service (auto-migrates if host changed) | | `cf down ` | Stop service | | `cf restart ` | down + up | -| `cf update ` | pull + build + down + up | +| `cf update ` | pull + down + up | | `cf pull ` | Pull latest images | | `cf logs -f ` | Follow logs | | `cf ps` | Show status of all services | @@ -371,11 +336,11 @@ cf pull --all # Restart (down + up) cf restart plex -# Update (pull + build + down + up) - the end-to-end update command +# Update (pull + down + up) - the end-to-end update command cf update --all # Update state from reality (discovers running services + captures digests) -cf refresh # updates compose-farm-state.yaml and dockerfarm-log.toml +cf refresh # updates state.yaml and dockerfarm-log.toml cf refresh --dry-run # preview without writing # Validate config, traefik labels, mounts, and networks @@ -1059,8 +1024,6 @@ This makes the config truly declarative: comment out a service, run `cf apply`, ## Traefik Multihost Ingress (File Provider) -If everything runs on one host, you can skip this section and rely on Traefik's Docker provider. - If you run a single Traefik instance on one "front‑door" host and want it to route to Compose Farm services on other hosts, Compose Farm can generate a Traefik file‑provider fragment from your existing compose labels. diff --git a/docs/architecture.md b/docs/architecture.md index a4b0876..0c9ff1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,7 +115,7 @@ cli/ └─► Validate service exists 2. Check state - └─► Load compose-farm-state.yaml + └─► Load state.yaml └─► Is plex already running? └─► Is it on a different host? (migration needed) @@ -135,7 +135,7 @@ cli/ └─► Run: docker compose up -d 6. Update state - └─► Write new state to compose-farm-state.yaml + └─► Write new state to state.yaml 7. Generate Traefik config (if configured) └─► Regenerate traefik file-provider @@ -232,7 +232,7 @@ Syncs state with reality by querying Docker on each host: docker ps --format '{{.Names}}' ``` -Updates compose-farm-state.yaml to match what's actually running. +Updates state.yaml to match what's actually running. ## Compose File Discovery diff --git a/docs/assets/web-console.gif b/docs/assets/web-console.gif new file mode 100644 index 0000000..39ccd75 --- /dev/null +++ b/docs/assets/web-console.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fd5d0a55478b2228d3374fa7153f0a573f75f949b8dc373b467ef8313faf8fd +size 1722539 diff --git a/docs/assets/web-console.webm b/docs/assets/web-console.webm new file mode 100644 index 0000000..1ed94e6 --- /dev/null +++ b/docs/assets/web-console.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00fcd406dc567e778c6fac8cd6096b1ca28038d2da07a7380ffe6ae6baeab041 +size 1505468 diff --git a/docs/assets/web-navigation.gif b/docs/assets/web-navigation.gif new file mode 100644 index 0000000..365ff49 --- /dev/null +++ b/docs/assets/web-navigation.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52c5b293bd76f310a827778f26e64b4ef43a5d148b7b487e77ca89331bd7a19e +size 3348205 diff --git a/docs/assets/web-navigation.webm b/docs/assets/web-navigation.webm new file mode 100644 index 0000000..f90540b --- /dev/null +++ b/docs/assets/web-navigation.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f31d1bee465dea7b75933266679e21d77af95b80bf7138231f706f0d2534d3 +size 2750918 diff --git a/docs/assets/web-service.gif b/docs/assets/web-service.gif new file mode 100644 index 0000000..ea27dc5 --- /dev/null +++ b/docs/assets/web-service.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95e1a49cbe1b383ae82fcdbb2d5e25e100aa66f805f2974403b8ab45585dae40 +size 3582989 diff --git a/docs/assets/web-service.webm b/docs/assets/web-service.webm new file mode 100644 index 0000000..57a6ba6 --- /dev/null +++ b/docs/assets/web-service.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db3ef4e9336bf3d7840ccb68c1daf87adaad97809171c6460f6186a4c29248f3 +size 3111066 diff --git a/docs/assets/web-shell.gif b/docs/assets/web-shell.gif new file mode 100644 index 0000000..d94aad2 --- /dev/null +++ b/docs/assets/web-shell.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e6f2a1e932fc30801ec47dd6644fbc1e83878e33f80684952c938d8c1306274 +size 1491217 diff --git a/docs/assets/web-shell.webm b/docs/assets/web-shell.webm new file mode 100644 index 0000000..c6d72df --- /dev/null +++ b/docs/assets/web-shell.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a9591049f878763ba1bd715746e8e4f285ed2632d035a14023e5bb41f7489d7 +size 1201920 diff --git a/docs/assets/web-themes.gif b/docs/assets/web-themes.gif new file mode 100644 index 0000000..c1c5d8a --- /dev/null +++ b/docs/assets/web-themes.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ca1322cc59d0a50d008d3a8406cad2bd932c4e18656cb7af061da22847dd8d1 +size 5835390 diff --git a/docs/assets/web-themes.webm b/docs/assets/web-themes.webm new file mode 100644 index 0000000..bb34c0b --- /dev/null +++ b/docs/assets/web-themes.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfbb4dca6907497e89f81687adb3fc993727b569c035a33829e069473874e40d +size 6532718 diff --git a/docs/assets/web-workflow.gif b/docs/assets/web-workflow.gif new file mode 100644 index 0000000..3799f29 --- /dev/null +++ b/docs/assets/web-workflow.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75e10d1939442b4d2bd78766bb99e7a8a7aa9b15b7c3094ee974c387d35afb81 +size 6504399 diff --git a/docs/assets/web-workflow.webm b/docs/assets/web-workflow.webm new file mode 100644 index 0000000..153f87b --- /dev/null +++ b/docs/assets/web-workflow.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46ebcbaf3db613e0e77d4ed0c170c58c5c5c584eb4d8a078b514e6c2c933a829 +size 6933630 diff --git a/docs/best-practices.md b/docs/best-practices.md index 3f9c245..90ee4e2 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -185,8 +185,6 @@ cf apply ## Shared Storage -Shared storage is only required for multi-host deployments. - ### NFS Best Practices ```bash @@ -299,7 +297,7 @@ http: |------|----------|--------| | Compose Farm config | `~/.config/compose-farm/` | Git or copy | | Compose files | `/opt/compose/` | Git | -| State file | `~/.config/compose-farm/compose-farm-state.yaml` | Optional (can refresh) | +| State file | `~/.config/compose-farm/state.yaml` | Optional (can refresh) | | App data | `/opt/appdata/` | Backup solution | ### Disaster Recovery @@ -343,6 +341,15 @@ cf ssh status # Check key status cf ssh setup # Re-setup keys ``` +### Debug Mode + +For more verbose output: + +```bash +# See exact commands being run +cf --verbose up myservice +``` + ## Security Considerations ### SSH Keys diff --git a/docs/commands.md b/docs/commands.md index d50131b..8c9bc5a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -14,7 +14,7 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias ` | | `up` | Start services | | | `down` | Stop services | | | `restart` | Restart services (down + up) | -| | `update` | Update services (pull + build + down + up) | +| | `update` | Update services (pull + down + up) | | | `pull` | Pull latest images | | **Monitoring** | `ps` | Show service status | | | `logs` | Show service logs | diff --git a/docs/configuration.md b/docs/configuration.md index 6a4a0b6..b3ce7a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,29 +27,10 @@ Or set the environment variable: export CF_CONFIG=/path/to/config.yaml ``` -## Examples - -### Single host (local-only) +## Full Example ```yaml # Required: directory containing compose files -compose_dir: /opt/stacks - -# Define local host -hosts: - local: localhost - -# Map services to the local host -services: - plex: local - sonarr: local - radarr: local -``` - -### Multi-host (full example) - -```yaml -# Required: directory containing compose files (same path on all hosts) compose_dir: /opt/compose # Optional: auto-regenerate Traefik config @@ -64,6 +45,7 @@ hosts: hp: address: 192.168.1.11 user: admin + local: localhost # Map services to hosts services: @@ -71,6 +53,7 @@ services: plex: nuc sonarr: nuc radarr: hp + jellyfin: local # Multi-host services dozzle: all # Run on ALL hosts diff --git a/docs/demos/README.md b/docs/demos/README.md index 486c6b5..d12a026 100644 --- a/docs/demos/README.md +++ b/docs/demos/README.md @@ -1,26 +1,17 @@ -# Terminal Demos +# Demo Recordings -[VHS](https://github.com/charmbracelet/vhs) tape files for recording terminal demos. +Demo recording infrastructure for Compose Farm documentation. -## Demos +## Structure -| File | Shows | -|------|-------| -| `install.tape` | Installing with `uv tool install` | -| `quickstart.tape` | `cf ps`, `cf up`, `cf logs` | -| `logs.tape` | Viewing logs | -| `update.tape` | `cf update` | -| `migration.tape` | Service migration | -| `apply.tape` | `cf apply` | - -## Recording - -```bash -# Record all demos (outputs to docs/assets/) -./docs/demos/record.sh - -# Single demo -cd /opt/stacks && vhs /path/to/docs/demos/quickstart.tape +``` +docs/demos/ +├── cli/ # VHS-based CLI terminal recordings +└── web/ # Playwright-based web UI recordings ``` -Output files (GIF + WebM) are tracked with Git LFS. +## Output + +All recordings output to `docs/assets/` as WebM (primary) and GIF (fallback). + +See subdirectory READMEs for usage. diff --git a/docs/demos/cli/README.md b/docs/demos/cli/README.md new file mode 100644 index 0000000..cfabecd --- /dev/null +++ b/docs/demos/cli/README.md @@ -0,0 +1,32 @@ +# CLI Demo Recordings + +VHS-based terminal demo recordings for Compose Farm CLI. + +## Requirements + +- [VHS](https://github.com/charmbracelet/vhs): `go install github.com/charmbracelet/vhs@latest` + +## Usage + +```bash +# Record all demos +./docs/demos/cli/record.sh + +# Record single demo +cd /opt/stacks && vhs docs/demos/cli/quickstart.tape +``` + +## Demos + +| Tape | Description | +|------|-------------| +| `install.tape` | Installing with `uv tool install` | +| `quickstart.tape` | `cf ps`, `cf up`, `cf logs` | +| `logs.tape` | Viewing logs | +| `update.tape` | `cf update` | +| `migration.tape` | Service migration | +| `apply.tape` | `cf apply` | + +## Output + +GIF and WebM files saved to `docs/assets/`. diff --git a/docs/demos/apply.tape b/docs/demos/cli/apply.tape similarity index 100% rename from docs/demos/apply.tape rename to docs/demos/cli/apply.tape diff --git a/docs/demos/install.tape b/docs/demos/cli/install.tape similarity index 100% rename from docs/demos/install.tape rename to docs/demos/cli/install.tape diff --git a/docs/demos/logs.tape b/docs/demos/cli/logs.tape similarity index 100% rename from docs/demos/logs.tape rename to docs/demos/cli/logs.tape diff --git a/docs/demos/migration.tape b/docs/demos/cli/migration.tape similarity index 100% rename from docs/demos/migration.tape rename to docs/demos/cli/migration.tape diff --git a/docs/demos/quickstart.tape b/docs/demos/cli/quickstart.tape similarity index 100% rename from docs/demos/quickstart.tape rename to docs/demos/cli/quickstart.tape diff --git a/docs/demos/record.sh b/docs/demos/cli/record.sh similarity index 97% rename from docs/demos/record.sh rename to docs/demos/cli/record.sh index 671ed31..f3d4c10 100755 --- a/docs/demos/record.sh +++ b/docs/demos/cli/record.sh @@ -5,7 +5,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOCS_DIR="$(dirname "$SCRIPT_DIR")" +DEMOS_DIR="$(dirname "$SCRIPT_DIR")" +DOCS_DIR="$(dirname "$DEMOS_DIR")" REPO_DIR="$(dirname "$DOCS_DIR")" OUTPUT_DIR="$DOCS_DIR/assets" diff --git a/docs/demos/update.tape b/docs/demos/cli/update.tape similarity index 90% rename from docs/demos/update.tape rename to docs/demos/cli/update.tape index 4f23406..f951532 100644 --- a/docs/demos/update.tape +++ b/docs/demos/cli/update.tape @@ -1,5 +1,5 @@ # Update Demo -# Shows updating services (pull + build + down + up) +# Shows updating services (pull + down + up) Output docs/assets/update.gif Output docs/assets/update.webm diff --git a/docs/demos/web/README.md b/docs/demos/web/README.md new file mode 100644 index 0000000..98d9b81 --- /dev/null +++ b/docs/demos/web/README.md @@ -0,0 +1,45 @@ +# Web UI Demo Recordings + +Playwright-based demo recording for Compose Farm web UI. + +## Requirements + +- Chromium: `playwright install chromium` +- ffmpeg: `apt install ffmpeg` or `brew install ffmpeg` + +## Usage + +```bash +# Record all demos +python docs/demos/web/record.py + +# Record specific demo +python docs/demos/web/record.py navigation +``` + +## Demos + +| Demo | Description | +|------|-------------| +| `navigation` | Command palette fuzzy search and navigation | +| `service` | Service restart/logs via command palette | +| `themes` | Theme switching with arrow key preview | +| `workflow` | Full workflow: filter, navigate, logs, themes | +| `console` | Console terminal running cf commands | +| `shell` | Container shell exec with top | + +## Output + +WebM and GIF files saved to `docs/assets/web-{demo}.{webm,gif}`. + +## Files + +- `record.py` - Orchestration script +- `conftest.py` - Playwright fixtures, helper functions +- `demo_*.py` - Individual demo scripts + +## Notes + +- Uses real config at `/opt/stacks/compose-farm.yaml` +- Adjust `pause(page, ms)` calls to control timing +- Viewport: 1280x720 diff --git a/docs/demos/web/__init__.py b/docs/demos/web/__init__.py new file mode 100644 index 0000000..48933f6 --- /dev/null +++ b/docs/demos/web/__init__.py @@ -0,0 +1 @@ +"""Web UI demo recording scripts.""" diff --git a/docs/demos/web/conftest.py b/docs/demos/web/conftest.py new file mode 100644 index 0000000..80d935a --- /dev/null +++ b/docs/demos/web/conftest.py @@ -0,0 +1,220 @@ +"""Shared fixtures for web UI demo recordings. + +Based on tests/web/test_htmx_browser.py patterns for consistency. +""" + +from __future__ import annotations + +import os +import re +import shutil +import socket +import threading +import time +import urllib.request +from pathlib import Path +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import pytest +import uvicorn + +from compose_farm.config import Config as CFConfig +from compose_farm.config import load_config +from compose_farm.web.app import create_app +from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache + +if TYPE_CHECKING: + from collections.abc import Generator + + from playwright.sync_api import BrowserContext, Page, Route + +# Services to exclude from demo recordings (exact match) +DEMO_EXCLUDE_SERVICES = {"arr"} + + +def _get_filtered_config() -> CFConfig: + """Load config but filter out excluded services.""" + config = load_config() + # Filter out excluded services + filtered_services = { + name: host for name, host in config.services.items() if name not in DEMO_EXCLUDE_SERVICES + } + # Create a new config with filtered services + return CFConfig( + compose_dir=config.compose_dir, + hosts=config.hosts, + services=filtered_services, + traefik_file=config.traefik_file, + traefik_service=config.traefik_service, + config_path=config.config_path, + ) + + +@pytest.fixture(scope="session") +def vendor_cache(request: pytest.FixtureRequest) -> Path: + """Download CDN assets once and cache to disk for faster recordings.""" + cache_dir = Path(str(request.config.rootdir)) / ".pytest_cache" / "vendor" + return ensure_vendor_cache(cache_dir) + + +@pytest.fixture(scope="session") +def browser_type_launch_args() -> dict[str, str]: + """Configure Playwright to use system Chromium if available.""" + for name in ["chromium", "chromium-browser", "google-chrome", "chrome"]: + path = shutil.which(name) + if path: + return {"executable_path": path} + return {} + + +# Path to real compose-farm config +REAL_CONFIG_PATH = Path("/opt/stacks/compose-farm.yaml") + + +@pytest.fixture(scope="module") +def server_url() -> Generator[str, None, None]: + """Start demo server using real config (with filtered services) and return URL.""" + os.environ["CF_CONFIG"] = str(REAL_CONFIG_PATH) + + # Patch get_config in all web modules to filter out excluded services + # Must patch where it's imported, not where it's defined + patches = [ + patch("compose_farm.web.routes.pages.get_config", _get_filtered_config), + patch("compose_farm.web.routes.api.get_config", _get_filtered_config), + patch("compose_farm.web.routes.actions.get_config", _get_filtered_config), + patch("compose_farm.web.app.get_config", _get_filtered_config), + patch("compose_farm.web.ws.get_config", _get_filtered_config), + ] + + # Start all patches + for p in patches: + p.start() + + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + app = create_app() + uvicorn_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error") + server = uvicorn.Server(uvicorn_config) + + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{port}" + server_ready = False + # Wait up to 5 seconds for server to start + for _ in range(50): + try: + urllib.request.urlopen(url, timeout=0.5) # noqa: S310 + server_ready = True + break + except Exception: + time.sleep(0.1) + + if not server_ready: + msg = f"Demo server failed to start on {url}" + raise RuntimeError(msg) + + yield url + + server.should_exit = True + thread.join(timeout=2) + os.environ.pop("CF_CONFIG", None) + + # Stop all patches + for p in patches: + p.stop() + + +@pytest.fixture(scope="module") +def recording_output_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Directory for video recordings.""" + return Path(tmp_path_factory.mktemp("recordings")) + + +@pytest.fixture +def recording_context( + browser: Any, # pytest-playwright's browser fixture + vendor_cache: Path, + recording_output_dir: Path, +) -> Generator[BrowserContext, None, None]: + """Browser context with video recording enabled.""" + context = browser.new_context( + viewport={"width": 1280, "height": 720}, + record_video_dir=str(recording_output_dir), + record_video_size={"width": 1280, "height": 720}, + ) + + # Set up CDN interception + cache = {url: (vendor_cache / f, ct) for url, (f, ct) in CDN_ASSETS.items()} + + def handle_cdn(route: Route) -> None: + url = route.request.url + for url_prefix, (filepath, content_type) in cache.items(): + if url.startswith(url_prefix): + route.fulfill(status=200, content_type=content_type, body=filepath.read_bytes()) + return + route.abort("failed") + + context.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn) + + yield context + context.close() + + +@pytest.fixture +def recording_page(recording_context: BrowserContext) -> Generator[Page, None, None]: + """Page with recording and slow motion enabled.""" + page = recording_context.new_page() + yield page + page.close() + + +# Demo helper functions + + +def pause(page: Page, ms: int = 500) -> None: + """Pause for visibility in recording.""" + page.wait_for_timeout(ms) + + +def slow_type(page: Page, selector: str, text: str, delay: int = 100) -> None: + """Type with visible delay between keystrokes.""" + page.type(selector, text, delay=delay) + + +def open_command_palette(page: Page) -> None: + """Open command palette with Ctrl+K.""" + page.keyboard.press("Control+k") + page.wait_for_selector("#cmd-palette[open]", timeout=2000) + pause(page, 300) + + +def close_command_palette(page: Page) -> None: + """Close command palette with Escape.""" + page.keyboard.press("Escape") + page.wait_for_selector("#cmd-palette:not([open])", timeout=2000) + pause(page, 200) + + +def wait_for_sidebar(page: Page) -> None: + """Wait for sidebar to load with services.""" + page.wait_for_selector("#sidebar-services", timeout=5000) + pause(page, 300) + + +def navigate_to_service(page: Page, service: str) -> None: + """Navigate to a service page via sidebar click.""" + page.locator("#sidebar-services a", has_text=service).click() + page.wait_for_url(f"**/service/{service}", timeout=5000) + pause(page, 500) + + +def select_command(page: Page, command: str) -> None: + """Filter and select a command from the palette.""" + page.locator("#cmd-input").fill(command) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 200) diff --git a/docs/demos/web/demo_console.py b/docs/demos/web/demo_console.py new file mode 100644 index 0000000..b87624d --- /dev/null +++ b/docs/demos/web/demo_console.py @@ -0,0 +1,73 @@ +"""Demo: Console terminal. + +Records a ~30 second demo showing: +- Navigating to Console page +- Running cf commands in the terminal +- Showing the Compose Farm config in Monaco editor + +Run: pytest docs/demos/web/demo_console.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import ( + pause, + slow_type, + wait_for_sidebar, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_console(recording_page: Page, server_url: str) -> None: + """Record console terminal demo.""" + page = recording_page + + # Start on dashboard + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 800) + + # Navigate to Console page via sidebar menu + page.locator(".menu a", has_text="Console").click() + page.wait_for_url("**/console", timeout=5000) + pause(page, 1000) + + # Wait for terminal to be ready (auto-connects) + page.wait_for_selector("#console-terminal .xterm", timeout=10000) + pause(page, 1500) + + # Run fastfetch first + slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=80) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 2500) # Wait for output + + # Type cf stats command + slow_type(page, "#console-terminal .xterm-helper-textarea", "cf stats", delay=80) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 3000) # Wait for output + + # Type cf ps command + slow_type(page, "#console-terminal .xterm-helper-textarea", "cf ps grocy", delay=80) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 2500) # Wait for output + + # Scroll down to show the Editor section with Compose Farm config + editor_section = page.locator(".collapse", has_text="Editor").first + editor_section.scroll_into_view_if_needed() + pause(page, 800) + + # Wait for Monaco editor to load with config content + page.wait_for_selector("#console-editor .monaco-editor", timeout=10000) + pause(page, 2500) # Let viewer see the Compose Farm config file + + # Final pause + pause(page, 800) diff --git a/docs/demos/web/demo_navigation.py b/docs/demos/web/demo_navigation.py new file mode 100644 index 0000000..e60dcea --- /dev/null +++ b/docs/demos/web/demo_navigation.py @@ -0,0 +1,74 @@ +"""Demo: Command palette navigation. + +Records a ~15 second demo showing: +- Opening command palette with Ctrl+K +- Fuzzy search filtering +- Arrow key navigation +- Service and page navigation + +Run: pytest docs/demos/web/demo_navigation.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import ( + open_command_palette, + pause, + slow_type, + wait_for_sidebar, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_navigation(recording_page: Page, server_url: str) -> None: + """Record command palette navigation demo.""" + page = recording_page + + # Start on dashboard + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 1000) # Let viewer see dashboard + + # Open command palette with keyboard shortcut + open_command_palette(page) + pause(page, 500) + + # Type partial service name for fuzzy search + slow_type(page, "#cmd-input", "grocy", delay=120) + pause(page, 800) + + # Arrow down to show selection movement + page.keyboard.press("ArrowDown") + pause(page, 400) + page.keyboard.press("ArrowUp") + pause(page, 400) + + # Press Enter to navigate to service + page.keyboard.press("Enter") + page.wait_for_url("**/service/grocy", timeout=5000) + pause(page, 1500) # Show service page + + # Open palette again to navigate elsewhere + open_command_palette(page) + pause(page, 400) + + # Navigate to another service (immich) to show more navigation + slow_type(page, "#cmd-input", "imm", delay=120) + pause(page, 600) + page.keyboard.press("Enter") + page.wait_for_url("**/service/immich", timeout=5000) + pause(page, 1200) # Show immich service page + + # Open palette one more time, navigate back to dashboard + open_command_palette(page) + slow_type(page, "#cmd-input", "dashb", delay=120) + pause(page, 500) + page.keyboard.press("Enter") + page.wait_for_url(server_url, timeout=5000) + pause(page, 1000) # Final dashboard view diff --git a/docs/demos/web/demo_service.py b/docs/demos/web/demo_service.py new file mode 100644 index 0000000..58f75f9 --- /dev/null +++ b/docs/demos/web/demo_service.py @@ -0,0 +1,96 @@ +"""Demo: Service actions. + +Records a ~30 second demo showing: +- Navigating to a service page +- Viewing compose file in Monaco editor +- Triggering Restart action via command palette +- Watching terminal output stream +- Triggering Logs action + +Run: pytest docs/demos/web/demo_service.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import ( + open_command_palette, + pause, + slow_type, + wait_for_sidebar, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_service(recording_page: Page, server_url: str) -> None: + """Record service actions demo.""" + page = recording_page + + # Start on dashboard + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 800) + + # Navigate to grocy via command palette + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "grocy", delay=100) + pause(page, 500) + page.keyboard.press("Enter") + page.wait_for_url("**/service/grocy", timeout=5000) + pause(page, 1000) # Show service page + + # Click on Compose File collapse to show the Monaco editor + # The collapse uses a checkbox input, click it via the parent collapse div + compose_collapse = page.locator(".collapse", has_text="Compose File").first + compose_collapse.locator("input[type=checkbox]").click(force=True) + pause(page, 500) + + # Wait for Monaco editor to load and show content + page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000) + pause(page, 2000) # Let viewer see the compose file + + # Scroll down slightly to show more of the editor + page.locator("#compose-editor").scroll_into_view_if_needed() + pause(page, 1500) + + # Close the compose file section + compose_collapse.locator("input[type=checkbox]").click(force=True) + pause(page, 500) + + # Open command palette for service actions + open_command_palette(page) + pause(page, 400) + + # Filter to Restart action + slow_type(page, "#cmd-input", "restart", delay=120) + pause(page, 600) + + # Execute Restart + page.keyboard.press("Enter") + pause(page, 300) + + # Wait for terminal to expand and show output + page.wait_for_selector("#terminal-output .xterm", timeout=5000) + pause(page, 2500) # Let viewer see terminal streaming + + # Open palette again for Logs + open_command_palette(page) + pause(page, 400) + + # Filter to Logs action + slow_type(page, "#cmd-input", "logs", delay=120) + pause(page, 600) + + # Execute Logs + page.keyboard.press("Enter") + pause(page, 300) + + # Show log output + page.wait_for_selector("#terminal-output .xterm", timeout=5000) + pause(page, 2500) # Final view of logs diff --git a/docs/demos/web/demo_shell.py b/docs/demos/web/demo_shell.py new file mode 100644 index 0000000..2254d29 --- /dev/null +++ b/docs/demos/web/demo_shell.py @@ -0,0 +1,71 @@ +"""Demo: Container shell exec. + +Records a ~25 second demo showing: +- Navigating to a service page +- Clicking Shell button on a container +- Running top command inside the container + +Run: pytest docs/demos/web/demo_shell.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import ( + pause, + slow_type, + wait_for_sidebar, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_shell(recording_page: Page, server_url: str) -> None: + """Record container shell demo.""" + page = recording_page + + # Start on dashboard + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 800) + + # Navigate to a service with a running container (grocy) + page.locator("#sidebar-services a", has_text="grocy").click() + page.wait_for_url("**/service/grocy", timeout=5000) + pause(page, 1500) + + # Wait for containers list to load (loaded via HTMX) + page.wait_for_selector("#containers-list button", timeout=10000) + pause(page, 800) + + # Click Shell button on the first container + shell_btn = page.locator("#containers-list button", has_text="Shell").first + shell_btn.click() + pause(page, 1000) + + # Wait for exec terminal to appear + page.wait_for_selector("#exec-terminal .xterm", timeout=10000) + + # Scroll down to make the terminal visible + page.locator("#exec-terminal").scroll_into_view_if_needed() + pause(page, 2000) + + # Run top command + slow_type(page, "#exec-terminal .xterm-helper-textarea", "top", delay=100) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 4000) # Let top run for a bit + + # Press q to quit top + page.keyboard.press("q") + pause(page, 1000) + + # Run another command to show it's interactive + slow_type(page, "#exec-terminal .xterm-helper-textarea", "ps aux | head", delay=60) + pause(page, 300) + page.keyboard.press("Enter") + pause(page, 2000) diff --git a/docs/demos/web/demo_themes.py b/docs/demos/web/demo_themes.py new file mode 100644 index 0000000..45a6aa0 --- /dev/null +++ b/docs/demos/web/demo_themes.py @@ -0,0 +1,81 @@ +"""Demo: Theme switching. + +Records a ~15 second demo showing: +- Opening theme picker via theme button +- Live theme preview on arrow navigation +- Selecting different themes +- Theme persistence + +Run: pytest docs/demos/web/demo_themes.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import ( + pause, + slow_type, + wait_for_sidebar, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_themes(recording_page: Page, server_url: str) -> None: + """Record theme switching demo.""" + page = recording_page + + # Start on dashboard + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 1000) # Show initial theme + + # Click theme button to open theme picker + page.locator("#theme-btn").click() + page.wait_for_selector("#cmd-palette[open]", timeout=2000) + pause(page, 600) + + # Arrow through many themes to show live preview effect + for _ in range(12): + page.keyboard.press("ArrowDown") + pause(page, 350) # Show each preview + + # Go back up through a few (land on valentine, not cyberpunk) + for _ in range(4): + page.keyboard.press("ArrowUp") + pause(page, 350) + + # Select current theme with Enter + page.keyboard.press("Enter") + pause(page, 1000) + + # Close palette with Escape + page.keyboard.press("Escape") + pause(page, 800) + + # Open again and use search to find specific theme + page.locator("#theme-btn").click() + page.wait_for_selector("#cmd-palette[open]", timeout=2000) + pause(page, 400) + + # Type to filter to a light theme (theme button pre-populates "theme:") + slow_type(page, "#cmd-input", " cup", delay=100) + pause(page, 500) + page.keyboard.press("Enter") + pause(page, 1000) + + # Close and return to dark + page.keyboard.press("Escape") + pause(page, 500) + page.locator("#theme-btn").click() + page.wait_for_selector("#cmd-palette[open]", timeout=2000) + pause(page, 300) + + slow_type(page, "#cmd-input", " dark", delay=100) + pause(page, 400) + page.keyboard.press("Enter") + pause(page, 800) diff --git a/docs/demos/web/demo_workflow.py b/docs/demos/web/demo_workflow.py new file mode 100644 index 0000000..acb4ddf --- /dev/null +++ b/docs/demos/web/demo_workflow.py @@ -0,0 +1,97 @@ +"""Demo: Full workflow. + +Records a ~45 second demo combining multiple features: +- Dashboard overview with stats +- Sidebar filtering +- Service navigation +- Terminal streaming +- Theme switching + +Run: pytest docs/demos/web/demo_workflow.py -v --no-cov +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from conftest import open_command_palette, pause, slow_type, wait_for_sidebar + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +def _demo_dashboard_and_filter(page: Page, server_url: str) -> None: + """Demo part 1: Dashboard overview and sidebar filtering.""" + page.goto(server_url) + wait_for_sidebar(page) + pause(page, 1500) + + stats_cards = page.locator("#stats-cards .card") + if stats_cards.count() > 0: + stats_cards.first.hover() + pause(page, 800) + + filter_input = page.locator("#sidebar-filter") + filter_input.click() + pause(page, 300) + slow_type(page, "#sidebar-filter", "jelly", delay=150) + filter_input.dispatch_event("keyup") + pause(page, 1000) + + +def _demo_service_and_logs(page: Page) -> None: + """Demo part 2: Navigate to service and view logs.""" + page.locator("#sidebar-services a", has_text="jellyfin").click() + page.wait_for_url("**/service/jellyfin", timeout=5000) + pause(page, 1500) + + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "logs", delay=150) + pause(page, 500) + page.keyboard.press("Enter") + pause(page, 300) + + page.wait_for_selector("#terminal-output .xterm", timeout=5000) + pause(page, 3000) + + +def _demo_theme_and_return(page: Page, server_url: str) -> None: + """Demo part 3: Switch theme and return to dashboard.""" + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "theme: luxury", delay=100) + pause(page, 600) + page.keyboard.press("Enter") + pause(page, 1500) + + open_command_palette(page) + pause(page, 400) + slow_type(page, "#cmd-input", "dash", delay=150) + pause(page, 500) + page.keyboard.press("Enter") + page.wait_for_url(server_url, timeout=5000) + pause(page, 1500) + + page.locator("#sidebar-filter").fill("") + page.locator("#sidebar-filter").dispatch_event("keyup") + pause(page, 1000) + + page.locator("#theme-btn").click() + page.wait_for_selector("#cmd-palette[open]", timeout=2000) + pause(page, 300) + page.locator("#cmd-input").fill("theme: dark") + pause(page, 500) + page.keyboard.press("Enter") + pause(page, 1000) + + +@pytest.mark.browser # type: ignore[misc] +def test_demo_workflow(recording_page: Page, server_url: str) -> None: + """Record full workflow demo.""" + page = recording_page + + _demo_dashboard_and_filter(page, server_url) + _demo_service_and_logs(page) + _demo_theme_and_return(page, server_url) diff --git a/docs/demos/web/record.py b/docs/demos/web/record.py new file mode 100755 index 0000000..8481ecd --- /dev/null +++ b/docs/demos/web/record.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Record all web UI demos. + +This script orchestrates recording of web UI demos using Playwright, +then converts the WebM recordings to GIF format. + +Usage: + python docs/demos/web/record.py # Record all demos + python docs/demos/web/record.py navigation # Record specific demo + +Requirements: + - Playwright with Chromium: playwright install chromium + - ffmpeg for GIF conversion: apt install ffmpeg / brew install ffmpeg +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Colors for output +GREEN = "\033[0;32m" +BLUE = "\033[0;34m" +YELLOW = "\033[0;33m" +RED = "\033[0;31m" +NC = "\033[0m" # No Color + +SCRIPT_DIR = Path(__file__).parent +REPO_DIR = SCRIPT_DIR.parent.parent.parent +OUTPUT_DIR = REPO_DIR / "docs" / "assets" + +DEMOS = [ + "navigation", + "service", + "themes", + "workflow", + "console", + "shell", +] + +# High-quality ffmpeg settings for VP8 encoding +# See: https://github.com/microsoft/playwright/issues/10855 +# See: https://github.com/microsoft/playwright/issues/31424 +# +# MAX_QUALITY: Lossless-like, largest files +# BALANCED_QUALITY: ~43% file size, nearly indistinguishable quality +MAX_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 0 -crf 0 -deadline best -speed 0 -b:v 0 -threads 0" +BALANCED_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 10 -crf 4 -deadline best -speed 0 -b:v 0 -threads 0" + +# Choose which quality to use +VIDEO_QUALITY_ARGS = MAX_QUALITY_ARGS + + +def patch_playwright_video_quality() -> None: + """Patch Playwright's videoRecorder.js to use high-quality encoding settings.""" + from playwright._impl._driver import compute_driver_executable # noqa: PLC0415 + + # compute_driver_executable returns (node_path, cli_path) + result = compute_driver_executable() + node_path = result[0] if isinstance(result, tuple) else result + driver_path = Path(node_path).parent + + video_recorder = driver_path / "package" / "lib" / "server" / "chromium" / "videoRecorder.js" + + if not video_recorder.exists(): + msg = f"videoRecorder.js not found at {video_recorder}" + raise FileNotFoundError(msg) + + content = video_recorder.read_text() + + # Check if already patched + if "deadline best" in content: + return # Already patched + + # Pattern to match the ffmpeg args line + pattern = ( + r"-c:v vp8 -qmin \d+ -qmax \d+ -crf \d+ -deadline \w+ -speed \d+ -b:v \w+ -threads \d+" + ) + + if not re.search(pattern, content): + msg = "Could not find ffmpeg args pattern in videoRecorder.js" + raise ValueError(msg) + + # Replace with high-quality settings + new_content = re.sub(pattern, VIDEO_QUALITY_ARGS, content) + video_recorder.write_text(new_content) + print(f"{GREEN}Patched Playwright for high-quality video recording{NC}") + + +def record_demo(name: str) -> Path | None: + """Run a single demo and return the video path.""" + print(f"{GREEN}Recording:{NC} web-{name}") + + demo_file = SCRIPT_DIR / f"demo_{name}.py" + if not demo_file.exists(): + print(f"{RED} Demo file not found: {demo_file}{NC}") + return None + + # Create temp output dir for this recording + temp_dir = SCRIPT_DIR / ".recordings" + temp_dir.mkdir(exist_ok=True) + + # Run pytest with video recording + # Set PYTHONPATH so conftest.py imports work + env = {**os.environ, "PYTHONPATH": str(SCRIPT_DIR)} + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + str(demo_file), + "-v", + "--no-cov", + "-x", # Stop on first failure + f"--basetemp={temp_dir}", + ], + check=False, + cwd=REPO_DIR, + capture_output=True, + text=True, + env=env, + ) + + if result.returncode != 0: + print(f"{RED} Failed to record {name}{NC}") + print(result.stdout) + print(result.stderr) + return None + + # Find the recorded video + videos = list(temp_dir.rglob("*.webm")) + if not videos: + print(f"{RED} No video found for {name}{NC}") + return None + + # Use the most recent video + video = max(videos, key=lambda p: p.stat().st_mtime) + print(f"{GREEN} Recorded: {video.name}{NC}") + return video + + +def convert_to_gif(webm_path: Path, output_name: str) -> Path: + """Convert WebM to GIF using ffmpeg with palette optimization.""" + gif_path = OUTPUT_DIR / f"{output_name}.gif" + palette_path = webm_path.parent / "palette.png" + + # Two-pass approach for better quality + # Pass 1: Generate palette + subprocess.run( + [ # noqa: S607 + "ffmpeg", + "-y", + "-i", + str(webm_path), + "-vf", + "fps=10,scale=1280:-1:flags=lanczos,palettegen=stats_mode=diff", + str(palette_path), + ], + check=True, + capture_output=True, + ) + + # Pass 2: Generate GIF with palette + subprocess.run( + [ # noqa: S607 + "ffmpeg", + "-y", + "-i", + str(webm_path), + "-i", + str(palette_path), + "-lavfi", + "fps=10,scale=1280:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", + str(gif_path), + ], + check=True, + capture_output=True, + ) + + palette_path.unlink(missing_ok=True) + return gif_path + + +def move_recording(video_path: Path, name: str) -> tuple[Path, Path]: + """Move WebM and convert to GIF, returning both paths.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + output_name = f"web-{name}" + webm_dest = OUTPUT_DIR / f"{output_name}.webm" + + shutil.copy2(video_path, webm_dest) + print(f"{BLUE} WebM: {webm_dest.relative_to(REPO_DIR)}{NC}") + + gif_path = convert_to_gif(video_path, output_name) + print(f"{BLUE} GIF: {gif_path.relative_to(REPO_DIR)}{NC}") + + return webm_dest, gif_path + + +def cleanup() -> None: + """Clean up temporary recording files.""" + temp_dir = SCRIPT_DIR / ".recordings" + if temp_dir.exists(): + shutil.rmtree(temp_dir) + + +def main() -> int: + """Record all web UI demos.""" + print(f"{BLUE}Recording web UI demos...{NC}") + print(f"Output directory: {OUTPUT_DIR}") + print() + + # Patch Playwright for high-quality video recording + patch_playwright_video_quality() + + # Determine which demos to record + if len(sys.argv) > 1: + demos_to_record = [d for d in sys.argv[1:] if d in DEMOS] + if not demos_to_record: + print(f"{RED}Unknown demo(s). Available: {', '.join(DEMOS)}{NC}") + return 1 + else: + demos_to_record = DEMOS + + results: dict[str, tuple[Path | None, Path | None]] = {} + + try: + for i, demo in enumerate(demos_to_record, 1): + print(f"{YELLOW}=== Demo {i}/{len(demos_to_record)}: {demo} ==={NC}") + + video_path = record_demo(demo) + if video_path: + webm, gif = move_recording(video_path, demo) + results[demo] = (webm, gif) + else: + results[demo] = (None, None) + print() + finally: + cleanup() + + # Summary + print(f"{BLUE}=== Summary ==={NC}") + success_count = sum(1 for w, _ in results.values() if w is not None) + print(f"Recorded: {success_count}/{len(demos_to_record)} demos") + print() + + for demo, (webm, gif) in results.items(): # type: ignore[assignment] + status = f"{GREEN}OK{NC}" if webm else f"{RED}FAILED{NC}" + print(f" {demo}: {status}") + if webm: + print(f" {webm.relative_to(REPO_DIR)}") + if gif: + print(f" {gif.relative_to(REPO_DIR)}") + + return 0 if success_count == len(demos_to_record) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/getting-started.md b/docs/getting-started.md index 15e1abd..80b73ee 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,22 +4,16 @@ icon: lucide/rocket # Getting Started -This guide walks you through installing Compose Farm and setting up your first deployment (single host or multi-host). +This guide walks you through installing Compose Farm and setting up your first multi-host deployment. ## Prerequisites -### Single host +Before you begin, ensure you have: - **[uv](https://docs.astral.sh/uv/)** (recommended) or Python 3.11+ -- **Docker and Docker Compose** installed -- **One folder per stack** under `compose_dir` - -### Multi-host - -- Everything above, plus: +- **SSH key-based authentication** to your Docker hosts - **Docker and Docker Compose** installed on all target hosts -- **SSH key-based authentication** to your Docker hosts (ssh-agent or `cf ssh setup` key) -- **Shared storage or sync** for compose files (NFS, Syncthing, etc.) +- **Shared storage** for compose files (NFS, Syncthing, etc.) ## Installation @@ -71,8 +65,6 @@ cf --help Compose Farm uses SSH to run commands on remote hosts. You need passwordless SSH access. -Skip this section if you're running a single host with `localhost`. - ### Option 1: SSH Agent (default) If you already have SSH keys loaded in your agent: @@ -101,8 +93,6 @@ This creates `~/.ssh/compose-farm/id_ed25519` and copies the public key to each ## Shared Storage Setup -This section applies to multi-host deployments only. - Compose files must be accessible at the **same path** on all hosts. Common approaches: ### NFS Mount @@ -135,23 +125,6 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0 Create `~/.config/compose-farm/compose-farm.yaml`: -#### Single host example - -```yaml -# Where compose files are located (one folder per stack) -compose_dir: /opt/stacks - -hosts: - local: localhost - -services: - plex: local - sonarr: local - radarr: local -``` - -#### Multi-host example - ```yaml # Where compose files are located (same path on all hosts) compose_dir: /opt/compose @@ -164,19 +137,16 @@ hosts: hp: address: 192.168.1.11 # user defaults to current user + local: localhost # Run locally without SSH # Map services to hosts services: plex: nuc sonarr: nuc radarr: hp - jellyfin: hp + jellyfin: local ``` -Each entry in `services:` maps to a folder under `compose_dir` that contains a compose file. - -For cross-host HTTP routing, add Traefik labels and configure `traefik_file` (see [Traefik Integration](traefik.md)). - ### Validate Configuration ```bash @@ -306,7 +276,7 @@ cf apply ```bash cf update --all -# Runs: pull + build + down + up for each service +# Runs: pull + down + up for each service ``` ## Next Steps diff --git a/docs/index.md b/docs/index.md index 577fce4..4ba5637 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,6 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH. Compose Farm lets you manage Docker Compose services across multiple machines from a single command line. Think [Dockge](https://dockge.kuma.pet/) but with a CLI and web interface, designed for multi-host deployments. Define which services run where in one YAML file, then use `cf apply` to make reality match your configuration. -It also works great on a single host with one folder per stack; just map services to `localhost`. ## Quick Demo @@ -32,31 +31,6 @@ It also works great on a single host with one folder per stack; just map service ## Quick Start -### Single host - -No SSH, shared storage, or Traefik file-provider required. - -```yaml -# compose-farm.yaml -compose_dir: /opt/stacks - -hosts: - local: localhost - -services: - plex: local - jellyfin: local - traefik: local -``` - -```bash -cf apply # Start/stop services to match config -``` - -### Multi-host - -Requires SSH plus a shared `compose_dir` path on all hosts (NFS or sync). - ```yaml # compose-farm.yaml compose_dir: /opt/compose @@ -77,10 +51,6 @@ services: cf apply # Services start, migrate, or stop as needed ``` -Each entry in `services:` maps to a folder under `compose_dir` that contains a compose file. - -For cross-host HTTP routing, add Traefik labels and configure `traefik_file` to generate file-provider config. - ### Installation ```bash @@ -140,25 +110,17 @@ cf logs -f plex ## Requirements -### Single host - - [uv](https://docs.astral.sh/uv/) (recommended) or Python 3.11+ -- Docker and Docker Compose installed -- One folder per stack under `compose_dir` - -### Multi-host - -- Everything above, plus: -- Docker and Docker Compose installed on all target hosts -- SSH key-based authentication to your Docker hosts (ssh-agent or `cf ssh setup` key) -- Shared storage or sync so `compose_dir` is the same path on all hosts -- Optional for ingress: Traefik file provider (labels + published ports) +- SSH key-based authentication to your Docker hosts +- Docker and Docker Compose on all target hosts +- Shared storage (compose files at same path on all hosts) ## Documentation - [Getting Started](getting-started.md) - Installation and first steps - [Configuration](configuration.md) - All configuration options - [Commands](commands.md) - CLI reference +- [Web UI](web-ui.md) - Browser-based management interface - [Architecture](architecture.md) - How it works under the hood - [Traefik Integration](traefik.md) - Multi-host routing setup - [Best Practices](best-practices.md) - Tips and limitations diff --git a/docs/traefik.md b/docs/traefik.md index d213975..f41818d 100644 --- a/docs/traefik.md +++ b/docs/traefik.md @@ -6,8 +6,6 @@ icon: lucide/globe Compose Farm can generate Traefik file-provider configuration for routing traffic across multiple hosts. -If everything runs on a single host, you can skip this and rely on Traefik's Docker provider. - ## The Problem When you run Traefik on one host but services on others, Traefik's docker provider can't see remote containers. The file provider bridges this gap. diff --git a/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 0000000..71536ad --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,130 @@ +--- +icon: lucide/layout-dashboard +--- + +# Web UI + +Compose Farm includes a web interface for managing services from your browser. Start it with: + +```bash +cf web +``` + +Then open [http://localhost:8000](http://localhost:8000). + +## Features + +### Command Palette + +Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes. + + + +### Service Actions + +Navigate to any service and use the command palette to trigger actions like restart, pull, update, or view logs. Output streams in real-time via WebSocket. + + + +### Theme Switching + +35 themes available via the command palette. Type `theme:` to filter, then use arrow keys to preview themes live before selecting. + + + +### Full Workflow + +Dashboard overview, sidebar filtering, service navigation, terminal streaming, and theme switching - all in one flow. + + + +## Pages + +### Dashboard (`/`) + +- Service overview with status indicators +- Host statistics +- Pending operations (migrations, orphaned services) +- Quick actions via command palette + +### Service Detail (`/service/{name}`) + +- Compose file editor (Monaco) +- Environment file editor +- Action buttons: Up, Down, Restart, Update, Pull, Logs +- Container shell access (exec into running containers) +- Terminal output for running commands + +### Console (`/console`) + +- Full shell access to any host +- File editor for remote files +- Monaco editor with syntax highlighting + + + +### Container Shell + +Click the Shell button on any running container to exec into it directly from the browser. + + + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+K` / `Cmd+K` | Open command palette | +| `Ctrl+S` / `Cmd+S` | Save editors | +| `Escape` | Close command palette | +| `Arrow keys` | Navigate command list | +| `Enter` | Execute selected command | + +## Starting the Server + +```bash +# Default: http://0.0.0.0:8000 +cf web + +# Custom port +cf web --port 3000 + +# Development mode with auto-reload +cf web --reload + +# Bind to specific interface +cf web --host 127.0.0.1 +``` + +## Requirements + +The web UI requires additional dependencies: + +```bash +# If installed via pip +pip install compose-farm[web] + +# If installed via uv +uv tool install compose-farm --with web +``` + +## Architecture + +The web UI uses: + +- **FastAPI** - Backend API and WebSocket handling +- **HTMX** - Dynamic page updates without full reloads +- **DaisyUI + Tailwind** - Theming and styling +- **Monaco Editor** - Code editing for compose/env files +- **xterm.js** - Terminal emulation for logs and shell access diff --git a/pyproject.toml b/pyproject.toml index 75f51e7..ef56bc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,10 @@ disallow_untyped_decorators = false module = "compose_farm.web.*" disallow_untyped_decorators = false +[[tool.mypy.overrides]] +module = "docs.demos.web.*" +disallow_untyped_decorators = false + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/compose_farm/web/cdn.py b/src/compose_farm/web/cdn.py new file mode 100644 index 0000000..e64748a --- /dev/null +++ b/src/compose_farm/web/cdn.py @@ -0,0 +1,108 @@ +"""CDN asset definitions and caching for tests and demo recordings. + +This module provides a single source of truth for CDN asset URLs used in +browser tests and demo recordings. Assets are intercepted and served from +a local cache to eliminate network variability. + +Note: The canonical list of CDN assets for production is in base.html +(with data-vendor attributes). This module includes those plus dynamically +loaded assets (like Monaco editor modules loaded by app.js). +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +# CDN assets to cache locally for tests/demos +# Format: URL -> (local_filename, content_type) +# +# If tests fail with "Uncached CDN request", add the URL here. +CDN_ASSETS: dict[str, tuple[str, str]] = { + # From base.html (data-vendor attributes) + "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"), + "https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"), + "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": ( + "tailwind.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"), + "https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"), + "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": ( + "xterm.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": ( + "xterm-fit.js", + "application/javascript", + ), + # Monaco editor - dynamically loaded by app.js + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": ( + "monaco-loader.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": ( + "monaco-editor-main.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": ( + "monaco-editor-main.css", + "text/css", + ), + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": ( + "monaco-workerMain.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": ( + "monaco-yaml.js", + "application/javascript", + ), + "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": ( + "monaco-codicon.ttf", + "font/ttf", + ), +} + + +def download_url(url: str) -> bytes | None: + """Download URL content using curl.""" + try: + result = subprocess.run( + ["curl", "-fsSL", "--max-time", "30", url], # noqa: S607 + capture_output=True, + check=True, + ) + return bytes(result.stdout) + except Exception: + return None + + +def ensure_vendor_cache(cache_dir: Path) -> Path: + """Download CDN assets to cache directory if not already present. + + Args: + cache_dir: Directory to store cached assets. + + Returns: + The cache directory path. + + Raises: + RuntimeError: If any asset fails to download. + + """ + cache_dir.mkdir(parents=True, exist_ok=True) + + for url, (filename, _content_type) in CDN_ASSETS.items(): + filepath = cache_dir / filename + if filepath.exists(): + continue + content = download_url(url) + if not content: + msg = f"Failed to download {url} - check network/curl" + raise RuntimeError(msg) + filepath.write_bytes(content) + + return cache_dir diff --git a/tests/web/test_htmx_browser.py b/tests/web/test_htmx_browser.py index a030b92..6211ffe 100644 --- a/tests/web/test_htmx_browser.py +++ b/tests/web/test_htmx_browser.py @@ -3,7 +3,8 @@ Run with: uv run pytest tests/web/test_htmx_browser.py -v --no-cov CDN assets are cached locally (in .pytest_cache/vendor/) to eliminate network -variability. If a test fails with "Uncached CDN request", add the URL to CDN_ASSETS. +variability. If a test fails with "Uncached CDN request", add the URL to +compose_farm.web.cdn.CDN_ASSETS. """ from __future__ import annotations @@ -25,57 +26,13 @@ import uvicorn from compose_farm.config import load_config from compose_farm.web import deps as web_deps from compose_farm.web.app import create_app +from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache from compose_farm.web.routes import api as web_api from compose_farm.web.routes import pages as web_pages if TYPE_CHECKING: from playwright.sync_api import Page, Route, WebSocket -# CDN assets to vendor locally for faster/more reliable tests -CDN_ASSETS = { - "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"), - "https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"), - "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": ( - "tailwind.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"), - "https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"), - "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": ( - "xterm.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": ( - "xterm-fit.js", - "application/javascript", - ), - # Monaco editor - loader.js plus dynamically loaded modules - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": ( - "monaco-loader.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": ( - "monaco-editor-main.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": ( - "monaco-editor-main.css", - "text/css", - ), - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": ( - "monaco-workerMain.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": ( - "monaco-yaml.js", - "application/javascript", - ), - "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": ( - "monaco-codicon.ttf", - "font/ttf", - ), -} - def _browser_available() -> bool: """Check if any chromium browser is available (system or Playwright-managed).""" @@ -103,43 +60,11 @@ pytestmark = [ ] -def _download_url(url: str) -> bytes | None: - """Download URL content using curl.""" - import subprocess - - try: - result = subprocess.run( - ["curl", "-fsSL", "--max-time", "30", url], # noqa: S607 - capture_output=True, - check=True, - ) - return bytes(result.stdout) - except Exception: - return None - - @pytest.fixture(scope="session") def vendor_cache(request: pytest.FixtureRequest) -> Path: - """Download CDN assets once and cache to disk for faster tests. - - Uses a persistent cache directory so assets are only downloaded once - across multiple test runs. Clear with: pytest --cache-clear - """ - # Use project-local cache directory + """Download CDN assets once and cache to disk for faster tests.""" cache_dir = Path(request.config.rootdir) / ".pytest_cache" / "vendor" - cache_dir.mkdir(parents=True, exist_ok=True) - - for url, (filename, _content_type) in CDN_ASSETS.items(): - filepath = cache_dir / filename - if filepath.exists(): - continue # Already cached - content = _download_url(url) - if not content: - msg = f"Failed to download {url} - check network/curl" - raise RuntimeError(msg) - filepath.write_bytes(content) - - return cache_dir + return ensure_vendor_cache(cache_dir) @pytest.fixture