mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-14 02:42:05 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5921b5e405 | ||
|
|
f0cd85b5f5 | ||
|
|
fe95443733 | ||
|
|
8df9288156 |
12
README.md
12
README.md
@@ -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:**
|
||||
|
||||

|
||||
|
||||
**Web UI:**
|
||||
|
||||

|
||||
|
||||
## 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 -->
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:92ed41854fe852ae54aa5f05de8ceaf35c3ad8ef82b3034e67edf758d1acdf50
|
||||
size 13593713
|
||||
oid sha256:ff2e3ca5a46397efcd5f3a595e7d3c179266cc4f3f5f528b428f5ef2a423028e
|
||||
size 12649149
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8507c61df25981dbe7e5bd2f9ed16c9a0befbca218947cad29f6679c77a695a7
|
||||
size 12451891
|
||||
oid sha256:2d739c5f77ddd9d90b609e31df620b35988081b7341fe225eb717d71a87caa88
|
||||
size 12284953
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user