mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-18 04:22:35 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7caf006e07 | ||
|
|
45040b75f1 | ||
|
|
fa1c5c1044 | ||
|
|
67e832f687 | ||
|
|
da986fab6a | ||
|
|
5dd6e2ca05 | ||
|
|
16435065de | ||
|
|
5921b5e405 | ||
|
|
f0cd85b5f5 | ||
|
|
fe95443733 | ||
|
|
8df9288156 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ compose-farm.yaml
|
||||
coverage.xml
|
||||
.env
|
||||
homepage/
|
||||
site/
|
||||
|
||||
16
README.md
16
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 -->
|
||||
|
||||
@@ -143,7 +155,7 @@ If you need containers on different hosts to communicate seamlessly, you need Do
|
||||
|
||||
```bash
|
||||
# One-liner (installs uv if needed)
|
||||
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
|
||||
curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
|
||||
# Or if you already have uv/pip
|
||||
uv tool install compose-farm
|
||||
@@ -225,7 +237,7 @@ The keys will persist across restarts.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`). This keeps config near your stacks. Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config, or symlink from one to the other with `cf config symlink`.
|
||||
|
||||
### Single-host example
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
|
||||
pause(page, 400)
|
||||
|
||||
# Type to filter to a light theme (theme button pre-populates "theme:")
|
||||
slow_type(page, "#cmd-input", " cup", delay=100)
|
||||
slow_type(page, "#cmd-input", "cup", delay=100)
|
||||
pause(page, 500)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1000)
|
||||
@@ -75,7 +75,7 @@ def test_demo_themes(recording_page: Page, server_url: str) -> None:
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 300)
|
||||
|
||||
slow_type(page, "#cmd-input", " dark", delay=100)
|
||||
slow_type(page, "#cmd-input", "dark", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 800)
|
||||
|
||||
@@ -24,7 +24,7 @@ Before you begin, ensure you have:
|
||||
### One-liner (recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
|
||||
curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
```
|
||||
|
||||
This installs [uv](https://docs.astral.sh/uv/) if needed, then installs compose-farm.
|
||||
@@ -123,7 +123,21 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0
|
||||
|
||||
### Create Config File
|
||||
|
||||
Create `~/.config/compose-farm/compose-farm.yaml`:
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands. For example, if your stacks are in `/opt/stacks`, place the config there too:
|
||||
|
||||
```bash
|
||||
cd /opt/stacks
|
||||
cf config init
|
||||
```
|
||||
|
||||
Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config. You can also symlink a working directory config to the global location:
|
||||
|
||||
```bash
|
||||
# Create config in your stacks directory, symlink to ~/.config
|
||||
cf config symlink /opt/stacks/compose-farm.yaml
|
||||
```
|
||||
|
||||
This way, `cf` commands work from anywhere while the config lives with your stacks.
|
||||
|
||||
#### Single host example
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ pip install compose-farm
|
||||
|
||||
### Configuration
|
||||
|
||||
Create `~/.config/compose-farm/compose-farm.yaml`:
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`), or in `~/.config/compose-farm/`:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
@@ -114,6 +114,8 @@ stacks:
|
||||
radarr: hp
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for all options and the full search order.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
|
||||
2
bootstrap.sh → docs/install
Executable file → Normal file
2
bootstrap.sh → docs/install
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Compose Farm bootstrap script
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/basnijholt/compose-farm/main/bootstrap.sh | sh
|
||||
# Usage: curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
#
|
||||
# This script installs uv (if needed) and then installs compose-farm as a uv tool.
|
||||
|
||||
21
docs/javascripts/video-fix.js
Normal file
21
docs/javascripts/video-fix.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Fix Safari video autoplay issues
|
||||
(function() {
|
||||
function initVideos() {
|
||||
document.querySelectorAll('video[autoplay]').forEach(function(video) {
|
||||
video.load();
|
||||
video.play().catch(function() {});
|
||||
});
|
||||
}
|
||||
|
||||
// For initial page load (needed for Chrome)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initVideos);
|
||||
} else {
|
||||
initVideos();
|
||||
}
|
||||
|
||||
// For MkDocs instant navigation (needed for Safari)
|
||||
if (typeof document$ !== 'undefined') {
|
||||
document$.subscribe(initVideos);
|
||||
}
|
||||
})();
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -10,11 +11,22 @@ from typing import TYPE_CHECKING
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from compose_farm.web.deps import STATIC_DIR, get_config
|
||||
from compose_farm.web.routes import actions, api, pages
|
||||
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
|
||||
|
||||
# Configure logging with Rich handler for compose_farm.web modules
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
|
||||
)
|
||||
# Set our web modules to INFO level (uvicorn handles its own logging)
|
||||
logging.getLogger("compose_farm.web").setLevel(logging.INFO)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
@@ -64,3 +64,19 @@ async def refresh_state() -> dict[str, Any]:
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
|
||||
return {"task_id": task_id, "command": "refresh"}
|
||||
|
||||
|
||||
@router.post("/pull-all")
|
||||
async def pull_all() -> dict[str, Any]:
|
||||
"""Pull latest images for all stacks."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["pull", "--all"], tid))
|
||||
return {"task_id": task_id, "command": "pull --all"}
|
||||
|
||||
|
||||
@router.post("/update-all")
|
||||
async def update_all() -> dict[str, Any]:
|
||||
"""Update all stacks (pull + build + down + up)."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
|
||||
return {"task_id": task_id, "command": "update --all"}
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -23,6 +24,8 @@ from compose_farm.paths import find_config_path
|
||||
from compose_farm.state import load_state
|
||||
from compose_farm.web.deps import get_config, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["api"])
|
||||
|
||||
|
||||
@@ -144,6 +147,12 @@ async def _get_container_states(
|
||||
config, stack, host_name, "ps -a --format json", stream=False
|
||||
)
|
||||
if not result.success:
|
||||
logger.warning(
|
||||
"Failed to get container states for %s on %s: %s",
|
||||
stack,
|
||||
host_name,
|
||||
result.stderr or result.stdout,
|
||||
)
|
||||
return containers
|
||||
|
||||
# Build state map: name -> (state, exit_code)
|
||||
@@ -350,6 +359,7 @@ async def read_console_file(
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
|
||||
except Exception as e:
|
||||
logger.exception("Failed to read file %s from host %s", path, host)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@@ -373,4 +383,5 @@ async def write_console_file(
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
|
||||
except Exception as e:
|
||||
logger.exception("Failed to write file %s to host %s", path, host)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/* Tooltips - ensure they appear above sidebar and other elements */
|
||||
.tooltip::before,
|
||||
.tooltip::after {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Sidebar inputs - remove focus outline (DaisyUI 5 uses outline + outline-offset) */
|
||||
#sidebar .input:focus,
|
||||
#sidebar .input:focus-within,
|
||||
|
||||
@@ -601,7 +601,9 @@ function playFabIntro() {
|
||||
}
|
||||
|
||||
function filter() {
|
||||
const q = input.value.toLowerCase();
|
||||
// Normalize: collapse spaces and ensure space after colon for matching
|
||||
// This allows "theme:dark", "theme: dark", "theme: dark" to all match "theme: dark"
|
||||
const q = input.value.toLowerCase().replace(/\s+/g, ' ').replace(/:(\S)/g, ': $1');
|
||||
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
|
||||
selected = Math.max(0, Math.min(selected, filtered.length - 1));
|
||||
}
|
||||
@@ -634,7 +636,7 @@ function playFabIntro() {
|
||||
input.value = initialFilter;
|
||||
filter();
|
||||
// If opening theme picker, select current theme
|
||||
if (initialFilter === 'theme:') {
|
||||
if (initialFilter.startsWith('theme:')) {
|
||||
const currentIdx = filtered.findIndex(c => c.themeId === originalTheme);
|
||||
if (currentIdx >= 0) selected = currentIdx;
|
||||
}
|
||||
@@ -759,9 +761,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 +791,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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<span class="font-semibold rainbow-hover">Compose Farm</span>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="flex-1 p-6 overflow-y-auto">
|
||||
<main id="main-content" class="flex-1 p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "partials/components.html" import page_header, collapse, stat_card, table, action_btn %}
|
||||
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database %}
|
||||
{% from "partials/icons.html" import check, refresh_cw, save, settings, server, database, cloud_download, rotate_cw %}
|
||||
{% block title %}Dashboard - Compose Farm{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -17,7 +17,9 @@
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
|
||||
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
|
||||
<button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button>
|
||||
{{ action_btn("Pull All", "/api/pull-all", "outline", "Pull latest images for all stacks", cloud_download()) }}
|
||||
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks (pull + build + down + up)", rotate_cw()) }}
|
||||
<div class="tooltip" data-tip="Save compose-farm.yaml config file"><button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button></div>
|
||||
</div>
|
||||
|
||||
{% include "partials/terminal.html" %}
|
||||
|
||||
@@ -25,12 +25,13 @@
|
||||
|
||||
{# Action button with htmx #}
|
||||
{% macro action_btn(label, url, style="outline", title=None, icon=None) %}
|
||||
{% if title %}<div class="tooltip" data-tip="{{ title }}">{% endif %}
|
||||
<button hx-post="{{ url }}"
|
||||
hx-swap="none"
|
||||
class="btn btn-{{ style }}"
|
||||
{% if title %}title="{{ title }}"{% endif %}>
|
||||
class="btn btn-{{ style }}">
|
||||
{% if icon %}{{ icon }}{% endif %}{{ label }}
|
||||
</button>
|
||||
{% if title %}</div>{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{# Stat card for dashboard #}
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
<span class="badge badge-warning">{{ container.State }}</span>
|
||||
{% endif %}
|
||||
<code class="text-sm flex-1">{{ container.Name }}</code>
|
||||
<button class="btn btn-sm btn-outline"
|
||||
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
|
||||
{{ terminal() }} Shell
|
||||
</button>
|
||||
<div class="tooltip tooltip-left" data-tip="Open shell in container">
|
||||
<button class="btn btn-sm btn-outline"
|
||||
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
|
||||
{{ terminal() }} Shell
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<!-- Other -->
|
||||
{{ action_btn("Pull", "/api/stack/" ~ name ~ "/pull", "outline", "Pull latest images (no restart)", cloud_download()) }}
|
||||
{{ action_btn("Logs", "/api/stack/" ~ name ~ "/logs", "outline", "Show recent logs", file_text()) }}
|
||||
<button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button>
|
||||
<div class="tooltip" data-tip="Save compose and .env files"><button id="save-btn" class="btn btn-outline">{{ save() }} Save All</button></div>
|
||||
</div>
|
||||
|
||||
{% call collapse("Compose File", badge=compose_path, icon=file_code()) %}
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import contextlib
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import shlex
|
||||
@@ -21,6 +22,8 @@ from compose_farm.executor import is_local, ssh_connect_kwargs
|
||||
from compose_farm.web.deps import get_config
|
||||
from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Shell command to prefer bash over sh
|
||||
SHELL_FALLBACK = "command -v bash >/dev/null && exec bash || exec sh"
|
||||
|
||||
@@ -214,6 +217,7 @@ async def exec_websocket(
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception("WebSocket exec error for %s on %s", container, host)
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
|
||||
finally:
|
||||
@@ -258,6 +262,7 @@ async def shell_websocket(
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception("WebSocket shell error for host %s", host)
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.send_text(f"{RED}Error: {e}{RESET}{CRLF}")
|
||||
finally:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,6 +11,7 @@ copyright = "Copyright © 2025 Bas Nijholt"
|
||||
repo_url = "https://github.com/basnijholt/compose-farm"
|
||||
repo_name = "GitHub"
|
||||
edit_uri = "edit/main/docs"
|
||||
extra_javascript = ["javascripts/video-fix.js"]
|
||||
|
||||
nav = [
|
||||
{ "Home" = "index.md" },
|
||||
@@ -48,16 +49,25 @@ features = [
|
||||
]
|
||||
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme)"
|
||||
toggle.icon = "lucide/sun-moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme: light)"
|
||||
scheme = "default"
|
||||
primary = "teal"
|
||||
primary = "indigo"
|
||||
accent = "indigo"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
media = "(prefers-color-scheme: dark)"
|
||||
scheme = "slate"
|
||||
primary = "teal"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
primary = "indigo"
|
||||
accent = "orange"
|
||||
toggle.icon = "lucide/moon-star"
|
||||
toggle.name = "Switch to system preference"
|
||||
|
||||
[project.theme.font]
|
||||
text = "Inter"
|
||||
@@ -67,6 +77,9 @@ code = "JetBrains Mono"
|
||||
logo = "lucide/server"
|
||||
repo = "lucide/github"
|
||||
|
||||
[project.extra]
|
||||
generator = false
|
||||
|
||||
[[project.extra.social]]
|
||||
icon = "fontawesome/brands/github"
|
||||
link = "https://github.com/basnijholt/compose-farm"
|
||||
|
||||
Reference in New Issue
Block a user