docs: improve Web UI workflow demo with comprehensive showcase (#78)

This commit is contained in:
Bas Nijholt
2025-12-20 16:14:33 -08:00
committed by GitHub
parent 350947ad12
commit 124bde7575
16 changed files with 223 additions and 118 deletions

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7fd5d0a55478b2228d3374fa7153f0a573f75f949b8dc373b467ef8313faf8fd
size 1722539
oid sha256:dac5660cfe6574857ec055fac7822f25b7c5fcb10a836b19c86142515e2fbf75
size 1816075

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00fcd406dc567e778c6fac8cd6096b1ca28038d2da07a7380ffe6ae6baeab041
size 1505468
oid sha256:d4efec8ef5a99f2cb31d55cd71cdbf0bb8dd0cd6281571886b7c1f8b41c3f9da
size 1660764

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:52c5b293bd76f310a827778f26e64b4ef43a5d148b7b487e77ca89331bd7a19e
size 3348205
oid sha256:9348dd36e79192344476d61fbbffdb122a96ecc5829fbece1818590cfc521521
size 3373003

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62f31d1bee465dea7b75933266679e21d77af95b80bf7138231f706f0d2534d3
size 2750918
oid sha256:bebbf8151434ba37bf5e46566a4e8b57812944281926f579d056bdc835ca26aa
size 2729799

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e6f2a1e932fc30801ec47dd6644fbc1e83878e33f80684952c938d8c1306274
size 1491217
oid sha256:3712afff6fcde00eb951264bb24d4301deb085d082b4e95ed4c1893a571938ee
size 1528294

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a9591049f878763ba1bd715746e8e4f285ed2632d035a14023e5bb41f7489d7
size 1201920
oid sha256:0b218d400836a50661c9cdcce2d2b1e285cc5fe592cb42f58aae41f3e7d60684
size 1327413

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:95e1a49cbe1b383ae82fcdbb2d5e25e100aa66f805f2974403b8ab45585dae40
size 3582989
oid sha256:6a232ddc1b9ddd9bf6b5d99c05153e1094be56f1952f02636ca498eb7484e096
size 3808675

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db3ef4e9336bf3d7840ccb68c1daf87adaad97809171c6460f6186a4c29248f3
size 3111066
oid sha256:5a7c9f5f6d47074a6af135190fda6d0a1936cd7a0b04b3aa04ea7d99167a9e05
size 3333014

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ca1322cc59d0a50d008d3a8406cad2bd932c4e18656cb7af061da22847dd8d1
size 5835390
oid sha256:66f4547ed2e83b302d795875588d9a085af76071a480f1096f2bb64344b80c42
size 5428670

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dfbb4dca6907497e89f81687adb3fc993727b569c035a33829e069473874e40d
size 6532718
oid sha256:75c8cdeefbbdcab2a240821d3410539f2a2cbe0a015897f4135404c80c3ac32c
size 6578366

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75e10d1939442b4d2bd78766bb99e7a8a7aa9b15b7c3094ee974c387d35afb81
size 6504399
oid sha256:92ed41854fe852ae54aa5f05de8ceaf35c3ad8ef82b3034e67edf758d1acdf50
size 13593713

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46ebcbaf3db613e0e77d4ed0c170c58c5c5c584eb4d8a078b514e6c2c933a829
size 6933630
oid sha256:8507c61df25981dbe7e5bd2f9ed16c9a0befbca218947cad29f6679c77a695a7
size 12451891

View File

@@ -21,6 +21,7 @@ import uvicorn
from compose_farm.config import Config as CFConfig
from compose_farm.config import load_config
from compose_farm.state import load_state as _original_load_state
from compose_farm.web.app import create_app
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
@@ -36,11 +37,9 @@ DEMO_EXCLUDE_STACKS = {"arr"}
def _get_filtered_config() -> CFConfig:
"""Load config but filter out excluded stacks."""
config = load_config()
# Filter out excluded stacks
filtered_stacks = {
name: host for name, host in config.stacks.items() if name not in DEMO_EXCLUDE_STACKS
}
# Create a new config with filtered stacks
return CFConfig(
compose_dir=config.compose_dir,
hosts=config.hosts,
@@ -51,6 +50,12 @@ def _get_filtered_config() -> CFConfig:
)
def _get_filtered_state(config: CFConfig) -> dict[str, str | list[str]]:
"""Load state but filter out excluded stacks."""
state = _original_load_state(config)
return {name: host for name, host in state.items() if name not in DEMO_EXCLUDE_STACKS}
@pytest.fixture(scope="session")
def vendor_cache(request: pytest.FixtureRequest) -> Path:
"""Download CDN assets once and cache to disk for faster recordings."""
@@ -77,9 +82,11 @@ def server_url() -> Generator[str, None, None]:
"""Start demo server using real config (with filtered stacks) and return URL."""
os.environ["CF_CONFIG"] = str(REAL_CONFIG_PATH)
# Patch get_config in all web modules to filter out excluded stacks
# Must patch where it's imported, not where it's defined
# Patch at source module level so all callers get filtered versions
patches = [
# Patch load_state at source - all functions calling it get filtered state
patch("compose_farm.state.load_state", _get_filtered_state),
# Patch get_config where imported
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),
@@ -87,7 +94,6 @@ def server_url() -> Generator[str, None, None]:
patch("compose_farm.web.ws.get_config", _get_filtered_config),
]
# Start all patches
for p in patches:
p.start()
@@ -104,7 +110,6 @@ def server_url() -> Generator[str, None, None]:
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
@@ -123,7 +128,6 @@ def server_url() -> Generator[str, None, None]:
thread.join(timeout=2)
os.environ.pop("CF_CONFIG", None)
# Stop all patches
for p in patches:
p.stop()

View File

@@ -1,11 +1,15 @@
"""Demo: Full workflow.
Records a ~45 second demo combining multiple features:
- Dashboard overview with stats
- Sidebar filtering
- Stack navigation
- Terminal streaming
- Theme switching
Records a comprehensive demo (~60 seconds) combining all major features:
1. Console page: terminal with fastfetch, cf pull command
2. Editor showing Compose Farm YAML config
3. Command palette navigation to grocy stack
4. Stack actions: up, logs
5. Switch to mealie stack via command palette, run update
6. Dashboard overview
7. Theme cycling via command palette
This demo is used on the homepage and Web UI page as the main showcase.
Run: pytest docs/demos/web/demo_workflow.py -v --no-cov
"""
@@ -21,68 +25,167 @@ 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."""
def _demo_console_terminal(page: Page, server_url: str) -> None:
"""Demo part 1: Console page with terminal and editor."""
# Start on dashboard briefly
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 1500)
pause(page, 800)
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()
# Navigate to Console page via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#sidebar-filter", "jelly", delay=150)
filter_input.dispatch_event("keyup")
slow_type(page, "#cmd-input", "cons", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/console", timeout=5000)
pause(page, 800)
# Wait for terminal to be ready
page.wait_for_selector("#console-terminal .xterm", timeout=10000)
pause(page, 1000)
def _demo_stack_and_logs(page: Page) -> None:
"""Demo part 2: Navigate to stack and view logs."""
page.locator("#sidebar-stacks a", has_text="jellyfin").click()
page.wait_for_url("**/stack/jellyfin", timeout=5000)
pause(page, 1500)
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "logs", delay=150)
pause(page, 500)
# Run fastfetch first
slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=60)
pause(page, 200)
page.keyboard.press("Enter")
pause(page, 2000) # Wait for output
# Run cf pull on a stack to show Compose Farm in action
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf pull grocy", delay=60)
pause(page, 200)
page.keyboard.press("Enter")
pause(page, 3000) # Wait for pull output
def _demo_config_editor(page: Page) -> None:
"""Demo part 2: Show the Compose Farm config in editor."""
# Smoothly scroll down to show the Editor section
# Use JavaScript for smooth scrolling animation
page.evaluate("""
const editor = document.getElementById('console-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Wait for Monaco editor to load with config content
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)
pause(page, 2000) # Let viewer see the Compose Farm config file
def _demo_stack_actions(page: Page) -> None:
"""Demo part 3: Navigate to stack and run actions."""
# Click on sidebar to take focus away from terminal, then use command palette
page.locator("#sidebar-stacks").click()
pause(page, 300)
# Navigate to grocy via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "grocy", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/grocy", timeout=5000)
pause(page, 1000)
# Open Compose File editor to show the compose.yaml
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
# Close the compose file section
compose_collapse.locator("input[type=checkbox]").click(force=True)
pause(page, 500)
# Run Up action via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "up", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
# Wait for terminal output
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
# Show logs
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "logs", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 3000)
pause(page, 2500)
def _demo_theme_and_return(page: Page, server_url: str) -> None:
"""Demo part 3: Switch theme and return to dashboard."""
# Switch to mealie via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "mealie", delay=100)
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")
page.wait_for_url("**/stack/mealie", timeout=5000)
pause(page, 1000)
# Run update action
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "upda", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
def _demo_dashboard_and_themes(page: Page, server_url: str) -> None:
"""Demo part 4: Dashboard and theme cycling."""
# Navigate to dashboard via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "dash", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=5000)
pause(page, 800)
# Scroll to top of page to ensure dashboard is fully visible
page.evaluate("window.scrollTo(0, 0)")
pause(page, 600)
# Open theme picker and arrow down to Luxury (shows live preview)
# Theme order: light, dark, cupcake, bumblebee, emerald, corporate, synthwave,
# retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel,
# fantasy, wireframe, black, luxury (index 19)
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 400)
# Arrow down through themes with live preview until we reach Luxury
for _ in range(19):
page.keyboard.press("ArrowDown")
pause(page, 180)
# Select Luxury theme
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 1000)
# Return to dark theme
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)
slow_type(page, "#cmd-input", " dark", delay=80)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 1000)
@@ -92,6 +195,7 @@ 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_stack_and_logs(page)
_demo_theme_and_return(page, server_url)
_demo_console_terminal(page, server_url)
_demo_config_editor(page)
_demo_stack_actions(page)
_demo_dashboard_and_themes(page, server_url)

View File

@@ -22,12 +22,9 @@ 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
from rich.console import Console
console = Console()
SCRIPT_DIR = Path(__file__).parent
REPO_DIR = SCRIPT_DIR.parent.parent.parent
@@ -88,16 +85,16 @@ def patch_playwright_video_quality() -> None:
# 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}")
console.print("[green]Patched Playwright for high-quality video recording[/green]")
def record_demo(name: str) -> Path | None:
"""Run a single demo and return the video path."""
print(f"{GREEN}Recording:{NC} web-{name}")
console.print(f"[green]Recording:[/green] 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}")
console.print(f"[red] Demo file not found: {demo_file}[/red]")
return None
# Create temp output dir for this recording
@@ -126,20 +123,20 @@ def record_demo(name: str) -> Path | None:
)
if result.returncode != 0:
print(f"{RED} Failed to record {name}{NC}")
print(result.stdout)
print(result.stderr)
console.print(f"[red] Failed to record {name}[/red]")
console.print(result.stdout)
console.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}")
console.print(f"[red] No video found for {name}[/red]")
return None
# Use the most recent video
video = max(videos, key=lambda p: p.stat().st_mtime)
print(f"{GREEN} Recorded: {video.name}{NC}")
console.print(f"[green] Recorded: {video.name}[/green]")
return video
@@ -193,10 +190,10 @@ def move_recording(video_path: Path, name: str) -> tuple[Path, Path]:
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}")
console.print(f"[blue] WebM: {webm_dest.relative_to(REPO_DIR)}[/blue]")
gif_path = convert_to_gif(video_path, output_name)
print(f"{BLUE} GIF: {gif_path.relative_to(REPO_DIR)}{NC}")
console.print(f"[blue] GIF: {gif_path.relative_to(REPO_DIR)}[/blue]")
return webm_dest, gif_path
@@ -210,9 +207,9 @@ def cleanup() -> None:
def main() -> int:
"""Record all web UI demos."""
print(f"{BLUE}Recording web UI demos...{NC}")
print(f"Output directory: {OUTPUT_DIR}")
print()
console.print("[blue]Recording web UI demos...[/blue]")
console.print(f"Output directory: {OUTPUT_DIR}")
console.print()
# Patch Playwright for high-quality video recording
patch_playwright_video_quality()
@@ -221,7 +218,7 @@ def main() -> int:
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}")
console.print(f"[red]Unknown demo(s). Available: {', '.join(DEMOS)}[/red]")
return 1
else:
demos_to_record = DEMOS
@@ -230,7 +227,7 @@ def main() -> int:
try:
for i, demo in enumerate(demos_to_record, 1):
print(f"{YELLOW}=== Demo {i}/{len(demos_to_record)}: {demo} ==={NC}")
console.print(f"[yellow]=== Demo {i}/{len(demos_to_record)}: {demo} ===[/yellow]")
video_path = record_demo(demo)
if video_path:
@@ -238,23 +235,23 @@ def main() -> int:
results[demo] = (webm, gif)
else:
results[demo] = (None, None)
print()
console.print()
finally:
cleanup()
# Summary
print(f"{BLUE}=== Summary ==={NC}")
console.print("[blue]=== Summary ===[/blue]")
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()
console.print(f"Recorded: {success_count}/{len(demos_to_record)} demos")
console.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}")
status = "[green]OK[/green]" if webm else "[red]FAILED[/red]"
console.print(f" {demo}: {status}")
if webm:
print(f" {webm.relative_to(REPO_DIR)}")
console.print(f" {webm.relative_to(REPO_DIR)}")
if gif:
print(f" {gif.relative_to(REPO_DIR)}")
console.print(f" {gif.relative_to(REPO_DIR)}")
return 0 if success_count == len(demos_to_record) else 1

View File

@@ -14,12 +14,12 @@ Then open [http://localhost:8000](http://localhost:8000).
## Features
### Command Palette
### Full Workflow
Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes.
Console terminal, config editor, stack navigation, actions (up, logs, update), dashboard overview, and theme switching - all in one flow.
<video autoplay loop muted playsinline>
<source src="/assets/web-navigation.webm" type="video/webm">
<source src="/assets/web-workflow.webm" type="video/webm">
</video>
### Stack Actions
@@ -38,12 +38,12 @@ Navigate to any stack and use the command palette to trigger actions like restar
<source src="/assets/web-themes.webm" type="video/webm">
</video>
### Full Workflow
### Command Palette
Dashboard overview, sidebar filtering, stack navigation, terminal streaming, and theme switching - all in one flow.
Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes.
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm" type="video/webm">
<source src="/assets/web-navigation.webm" type="video/webm">
</video>
## Pages