Compare commits

...

4 Commits

Author SHA1 Message Date
Bas Nijholt
5921b5e405 docs: update web-workflow demo recording (#83) 2025-12-20 18:09:24 -08:00
Bas Nijholt
f0cd85b5f5 fix: prevent terminal reconnection to wrong page after navigation (#81) 2025-12-20 16:41:28 -08:00
Bas Nijholt
fe95443733 fix: Safari video autoplay on first page load (#82) 2025-12-20 16:41:04 -08:00
Bas Nijholt
8df9288156 docs: add Quick Demo GIFs to README (#80)
* docs: add Quick Demo GIFs to README

Add the same CLI and Web UI demo GIFs that appear on the docs homepage.

* docs: add Table of Contents header
2025-12-20 16:20:42 -08:00
9 changed files with 177 additions and 21 deletions

View File

@@ -12,6 +12,18 @@ 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 stacks to hosts. Run `cf apply` and reality matches your config—stacks start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic.
## Quick Demo
**CLI:**
![CLI Demo](docs/assets/quickstart.gif)
**Web UI:**
![Web UI Demo](docs/assets/web-workflow.gif)
## Table of Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

View File

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

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8507c61df25981dbe7e5bd2f9ed16c9a0befbca218947cad29f6679c77a695a7
size 12451891
oid sha256:2d739c5f77ddd9d90b609e31df620b35988081b7341fe225eb717d71a87caa88
size 12284953

View File

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

View File

@@ -18,7 +18,7 @@ Before you begin, ensure you have:
## Installation
<video autoplay loop muted playsinline>
<source src="/assets/install.webm" type="video/webm">
<source src="/assets/install.webm#t=0.001" type="video/webm">
</video>
### One-liner (recommended)

View File

@@ -17,12 +17,12 @@ It also works great on a single host with one folder per stack; just map stacks
**CLI:**
<video autoplay loop muted playsinline>
<source src="/assets/quickstart.webm" type="video/webm">
<source src="/assets/quickstart.webm#t=0.001" type="video/webm">
</video>
**[Web UI](web-ui.md):**
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm" type="video/webm">
<source src="/assets/web-workflow.webm#t=0.001" type="video/webm">
</video>
## Why Compose Farm?
@@ -136,7 +136,7 @@ cf logs -f plex
- **Auto-migration**: Change a host assignment, run `cf up`, stack moves automatically
<video autoplay loop muted playsinline>
<source src="/assets/migration.webm" type="video/webm">
<source src="/assets/migration.webm#t=0.001" type="video/webm">
</video>
- **Parallel execution**: Multiple stacks start/stop concurrently
- **State tracking**: Knows which stacks are running where

View File

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

View File

@@ -759,9 +759,14 @@ function initPage() {
/**
* Attempt to reconnect to an active task from localStorage
* @param {string} [path] - Optional path to use for task key lookup.
* If not provided, uses current window.location.pathname.
* This is important for HTMX navigation where pushState
* hasn't happened yet when htmx:afterSwap fires.
*/
function tryReconnectToTask() {
const taskId = localStorage.getItem(getTaskKey());
function tryReconnectToTask(path) {
const taskKey = TASK_KEY_PREFIX + (path || window.location.pathname);
const taskId = localStorage.getItem(taskKey);
if (!taskId) return;
whenXtermReady(() => {
@@ -784,8 +789,12 @@ document.addEventListener('DOMContentLoaded', function() {
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'main-content') {
initPage();
// Try to reconnect when navigating back to dashboard
tryReconnectToTask();
// Try to reconnect to task for the TARGET page, not current URL.
// When using command palette navigation (htmx.ajax + manual pushState),
// window.location.pathname still reflects the OLD page at this point.
// Use pathInfo.requestPath to get the correct target path.
const targetPath = evt.detail.pathInfo?.requestPath?.split('?')[0] || window.location.pathname;
tryReconnectToTask(targetPath);
}
});

View File

@@ -1879,3 +1879,138 @@ class TestThemeSwitcher:
# Theme should still be dark
final = page.locator("html").get_attribute("data-theme")
assert final == "dark", f"Expected dark after executing Dashboard, got {final}"
class TestTerminalNavigationIsolation:
"""Test that terminal connections are properly isolated per page.
Regression tests for a bug where navigating away from a stack page via
command palette would cause the new page to reconnect to the old page's
terminal task because history.pushState hadn't updated the URL yet when
tryReconnectToTask() ran.
"""
def test_stack_terminal_not_reconnected_on_dashboard(self, page: Page, server_url: str) -> None:
"""Terminal started on stack page should NOT reconnect when navigating to dashboard.
Bug scenario:
1. On /stack/plex, click Update → terminal connects, task stored at cf_task:/stack/plex
2. Navigate to dashboard via command palette
3. Dashboard loads, htmx:afterSwap fires
4. tryReconnectToTask() runs but window.location.pathname is still /stack/plex
(because pushState hasn't run yet)
5. Bug: Dashboard reconnects to plex's terminal task
Expected: Dashboard should NOT have any task_id in its localStorage key (cf_task:/)
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=5000)
# Navigate to plex stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Clear any existing task state
page.evaluate("localStorage.clear()")
# Track WebSocket connections to see which terminals are opened
ws_urls: list[str] = []
def handle_ws(ws: WebSocket) -> None:
ws_urls.append(ws.url)
page.on("websocket", handle_ws)
# Mock Update API to return a task ID
page.route(
"**/api/stack/plex/update",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "plex-update-task-123", "stack": "plex", "command": "update"}',
),
)
# Wait for xterm to load
page.wait_for_function("typeof Terminal !== 'undefined'", timeout=5000)
# Click Update button on plex stack page
page.locator("button", has_text="Update").click()
# Wait for terminal to connect
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
page.wait_for_timeout(500)
# Verify task was stored for /stack/plex
plex_task = page.evaluate("localStorage.getItem('cf_task:/stack/plex')")
assert plex_task == "plex-update-task-123", (
f"Expected task stored at /stack/plex, got {plex_task}"
)
# Dashboard should have NO task yet
dashboard_task_before = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_before is None, (
f"Dashboard should have no task before navigation, got {dashboard_task_before}"
)
# Count current WebSocket connections
ws_count_before = len(ws_urls)
# Navigate to dashboard via command palette (this triggers the bug)
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
# Wait for navigation to complete
page.wait_for_url(server_url, timeout=5000)
page.wait_for_selector("#stats-cards", timeout=5000)
# Give time for any erroneous reconnection attempts
page.wait_for_timeout(1000)
# CRITICAL ASSERTION: Dashboard should NOT have a task in localStorage
dashboard_task_after = page.evaluate("localStorage.getItem('cf_task:/')")
assert dashboard_task_after is None, (
f"Bug detected: Dashboard incorrectly has task '{dashboard_task_after}' in localStorage. "
"This means tryReconnectToTask() ran before pushState updated the URL."
)
# CRITICAL ASSERTION: No new WebSocket should have been opened for the plex task
# after navigating to dashboard
new_ws_urls = ws_urls[ws_count_before:]
plex_reconnect_attempts = [url for url in new_ws_urls if "plex-update-task-123" in url]
assert len(plex_reconnect_attempts) == 0, (
f"Bug detected: Dashboard attempted to reconnect to plex task. "
f"New WebSocket URLs after navigation: {new_ws_urls}"
)
def test_dashboard_terminal_not_shown_after_stack_navigation(
self, page: Page, server_url: str
) -> None:
"""Dashboard's terminal should remain collapsed when navigating away and back.
This tests that navigating from dashboard → stack → dashboard doesn't
cause the terminal to expand unexpectedly.
"""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=5000)
# Terminal should be collapsed on dashboard
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should be collapsed initially"
# Navigate to a stack
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Navigate back to dashboard via command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
page.locator("#cmd-input").fill("Dashboard")
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=5000)
# Terminal should still be collapsed (no task to reconnect to)
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should remain collapsed after navigation"