Compare commits

...

6 Commits

Author SHA1 Message Date
Bas Nijholt
cc54e89b33 feat: add justfile for development commands (#99)
- Adds a justfile with common development commands for easier workflow
- Commands: `install`, `test`, `test-unit`, `test-browser`, `lint`, `web`, `kill-web`, `doc`, `kill-doc`, `clean`
2025-12-20 23:24:30 -08:00
Bas Nijholt
f71e5cffd6 feat(web): add service commands to command palette with fuzzy matching (#95)
- Add service-level commands to the command palette when viewing a stack detail page
- Services are extracted from the compose file and exposed via a `data-services` attribute
- Commands are grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
- Service commands appear with a teal indicator to distinguish from stack-level commands (green)
- Implement word-boundary fuzzy matching for better filtering UX:
  - `rest plex` matches `Restart: plex-server`
  - `server` matches `plex-server` (hyphenated names split into words)
  - Query words must match the START of command words (prevents false positives like `r ba` matching `Logs: bazarr`)

Available service commands:
- `Restart: <service>` - Restart a specific service
- `Pull: <service>` - Pull image for a service
- `Logs: <service>` - View logs for a service
- `Stop: <service>` - Stop a service
- `Up: <service>` - Start a service
2025-12-20 23:23:53 -08:00
Bas Nijholt
0e32729763 fix(web): add tooltips to console page buttons (#98)
* fix(web): add tooltips to console page buttons

Add descriptive tooltips to Connect, Open, and Save buttons on the
console page, matching the tooltip style used on dashboard and stack
pages.

* fix(web): show platform-appropriate keyboard shortcuts

Detect Mac vs other platforms and display ⌘ or Ctrl accordingly for
keyboard shortcuts. The command palette FAB dynamically updates, and
tooltips use ⌘/Ctrl notation to cover both platforms.
2025-12-20 22:59:50 -08:00
Bas Nijholt
b0b501fa98 docs: update example services in documentation and tests (#96) 2025-12-20 22:45:13 -08:00
Bas Nijholt
7e00596046 docs: fix inaccuracies and add missing command documentation (#97)
- Add missing --service option docs for up, stop, restart, update, pull, ps, logs
- Add stop command to command overview table
- Add compose passthrough command documentation
- Add --all option and [STACKS] argument to refresh command
- Fix ServiceConfig reference to Host in architecture.md
- Update lifecycle.py description to include stop and compose commands
- Fix uv installation syntax in web-ui.md (--with web -> [web])
- Add missing cf ssh --help and cf web --help output blocks in README
2025-12-20 22:37:26 -08:00
Bas Nijholt
d1e4d9b05c docs: update documentation for new CLI features (#94) 2025-12-20 21:36:47 -08:00
22 changed files with 689 additions and 120 deletions

View File

@@ -75,6 +75,30 @@ Check for conflicts between documentation files:
- Command tables match across files
- Config examples are consistent
### 8. Recent Changes Check
Before starting the review:
- Run `git log --oneline -20` to see recent commits
- Look for commits with `feat:`, `fix:`, or that mention new options/commands
- Cross-reference these against the documentation to catch undocumented features
### 9. Auto-Generated Content
For README.md or docs with `<!-- CODE:BASH:START -->` blocks:
- Run `uv run markdown-code-runner <file>` to regenerate outputs
- Check for missing `<!-- OUTPUT:START -->` markers (blocks that never ran)
- Verify help output matches current CLI behavior
### 10. CLI Options Completeness
For each command, run `cf <command> --help` and verify:
- Every option shown in help is documented
- Short flags (-x) are listed alongside long flags (--xxx)
- Default values in help match documented defaults
## Output Format
Provide findings in these categories:

View File

@@ -15,7 +15,7 @@ src/compose_farm/
│ ├── app.py # Shared Typer app instance, version callback
│ ├── common.py # Shared helpers, options, progress bar utilities
│ ├── config.py # Config subcommand (init, show, path, validate, edit, symlink)
│ ├── lifecycle.py # up, down, pull, restart, update, apply commands
│ ├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose commands
│ ├── management.py # refresh, check, init-network, traefik-file commands
│ ├── monitoring.py # logs, ps, stats commands
│ ├── ssh.py # SSH key management (setup, status, keygen)
@@ -58,9 +58,24 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
- **Imports at top level**: Never add imports inside functions unless they are explicitly marked with `# noqa: PLC0415` and a comment explaining it speeds up CLI startup. Heavy modules like `pydantic`, `yaml`, and `rich.table` are lazily imported to keep `cf --help` fast.
## Development Commands
Use `just` for common tasks. Run `just` to list available commands:
| Command | Description |
|---------|-------------|
| `just install` | Install dev dependencies |
| `just test` | Run all tests |
| `just test-unit` | Run unit tests (parallel) |
| `just test-browser` | Run browser tests |
| `just lint` | Lint, format, and type check |
| `just web` | Start web UI (port 9001) |
| `just doc` | Build and serve docs (port 9002) |
| `just clean` | Clean build artifacts |
## Testing
Run tests with `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
Run tests with `just test` or `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
```bash
# Unit tests only (skip browser tests, can parallelize)

View File

@@ -270,7 +270,7 @@ hosts:
stacks:
plex: server-1
jellyfin: server-2
sonarr: server-1
grafana: server-1
# Multi-host stacks (run on multiple/all hosts)
autokuma: all # Runs on ALL configured hosts
@@ -984,6 +984,26 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- cf ssh --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf ssh [OPTIONS] COMMAND [ARGS]...
Manage SSH keys for passwordless authentication.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ keygen Generate SSH key (does not distribute to hosts). │
│ setup Generate SSH key and distribute to all configured hosts. │
│ status Show SSH key status and host connectivity. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
@@ -1118,6 +1138,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- cf web --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf web [OPTIONS]
Start the web UI server.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --host -H TEXT Host to bind to [default: 0.0.0.0] │
│ --port -p INTEGER Port to listen on [default: 8000] │
│ --reload -r Enable auto-reload for development │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>

View File

@@ -26,5 +26,5 @@ stacks:
traefik: server-1 # Traefik runs here
plex: server-2 # Stacks on other hosts get file-provider entries
jellyfin: server-2
sonarr: server-1
radarr: local
grafana: server-1
nextcloud: local

View File

@@ -47,8 +47,7 @@ Compose Farm follows three core principles:
Pydantic models for YAML configuration:
- **Config** - Root configuration with compose_dir, hosts, stacks
- **HostConfig** - Host address and SSH user
- **ServiceConfig** - Service-to-host mappings
- **Host** - Host address, SSH user, and port
Key features:
- Validation with Pydantic
@@ -62,7 +61,7 @@ Tracks deployment state in `compose-farm-state.yaml` (stored alongside the confi
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
Used for:
@@ -98,7 +97,7 @@ cli/
├── app.py # Shared Typer app, version callback
├── common.py # Shared helpers, options, progress utilities
├── config.py # config subcommand (init, show, path, validate, edit, symlink)
├── lifecycle.py # up, down, pull, restart, update, apply
├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose
├── management.py # refresh, check, init-network, traefik-file
├── monitoring.py # logs, ps, stats
├── ssh.py # SSH key management (setup, status, keygen)
@@ -208,7 +207,7 @@ Location: `compose-farm-state.yaml` (stored alongside the config file)
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
Image digests are stored separately in `dockerfarm-log.toml` (also in the config directory).

View File

@@ -221,7 +221,7 @@ Keep config and data separate:
/opt/appdata/ # Local: per-host app data
├── plex/
└── sonarr/
└── grafana/
```
## Performance
@@ -235,7 +235,7 @@ Compose Farm runs operations in parallel. For large deployments:
cf up --all
# Avoid: sequential updates when possible
for svc in plex sonarr radarr; do
for svc in plex grafana nextcloud; do
cf update $svc
done
```
@@ -249,7 +249,7 @@ SSH connections are reused within a command. For many operations:
cf update --all
# Multiple commands, multiple connections (slower)
cf update plex && cf update sonarr && cf update radarr
cf update plex && cf update grafana && cf update nextcloud
```
## Traefik Setup
@@ -297,7 +297,7 @@ http:
|------|----------|--------|
| Compose Farm config | `~/.config/compose-farm/` | Git or copy |
| Compose files | `/opt/compose/` | Git |
| State file | `~/.config/compose-farm/state.yaml` | Optional (can refresh) |
| State file | `~/.config/compose-farm/compose-farm-state.yaml` | Optional (can refresh) |
| App data | `/opt/appdata/` | Backup solution |
### Disaster Recovery

View File

@@ -13,9 +13,11 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias `
| **Lifecycle** | `apply` | Make reality match config |
| | `up` | Start stacks |
| | `down` | Stop stacks |
| | `stop` | Stop services without removing containers |
| | `restart` | Restart stacks (down + up) |
| | `update` | Update stacks (pull + build + down + up) |
| | `pull` | Pull latest images |
| | `compose` | Run any docker compose command |
| **Monitoring** | `ps` | Show stack status |
| | `logs` | Show stack logs |
| | `stats` | Show overview statistics |
@@ -97,19 +99,23 @@ cf up [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Start all stacks |
| `--host, -H TEXT` | Filter to stacks on this host |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Start specific stacks
cf up plex sonarr
cf up plex grafana
# Start all stacks
cf up --all
# Start all stacks on a specific host
cf up --all --host nuc
# Start a specific service within a stack
cf up immich --service database
```
**Auto-migration:**
@@ -158,9 +164,40 @@ cf down --all --host nuc
---
### cf stop
Stop services without removing containers.
```bash
cf stop [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Stop all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Stop specific stacks
cf stop plex
# Stop all stacks
cf stop --all
# Stop a specific service within a stack
cf stop immich --service database
```
---
### cf restart
Restart stacks (down + up).
Restart stacks (down + up). With `--service`, restarts just that service.
```bash
cf restart [OPTIONS] [STACKS]...
@@ -171,6 +208,7 @@ cf restart [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Restart all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -178,13 +216,16 @@ cf restart [OPTIONS] [STACKS]...
```bash
cf restart plex
cf restart --all
# Restart a specific service
cf restart immich --service database
```
---
### cf update
Update stacks (pull + build + down + up).
Update stacks (pull + build + down + up). With `--service`, updates just that service.
<video autoplay loop muted playsinline>
<source src="/assets/update.webm" type="video/webm">
@@ -199,6 +240,7 @@ cf update [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Update all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -209,6 +251,9 @@ cf update plex
# Update all stacks
cf update --all
# Update a specific service
cf update immich --service database
```
---
@@ -226,6 +271,7 @@ cf pull [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Pull for all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -233,6 +279,56 @@ cf pull [OPTIONS] [STACKS]...
```bash
cf pull plex
cf pull --all
# Pull a specific service
cf pull immich --service database
```
---
### cf compose
Run any docker compose command on a stack. This is a passthrough to docker compose for commands not wrapped by cf.
```bash
cf compose [OPTIONS] STACK COMMAND [ARGS]...
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `STACK` | Stack to operate on (use `.` for current dir) |
| `COMMAND` | Docker compose command to run |
| `ARGS` | Additional arguments passed to docker compose |
**Options:**
| Option | Description |
|--------|-------------|
| `--host, -H TEXT` | Filter to stacks on this host (required for multi-host stacks) |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Show docker compose help
cf compose mystack --help
# View running processes
cf compose mystack top
# List images
cf compose mystack images
# Interactive shell
cf compose mystack exec web bash
# View parsed config
cf compose mystack config
# Use current directory as stack
cf compose . ps
```
---
@@ -253,6 +349,7 @@ cf ps [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Show all stacks (default) |
| `--host, -H TEXT` | Filter to stacks on this host |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -262,10 +359,13 @@ cf ps [OPTIONS] [STACKS]...
cf ps
# Show specific stacks
cf ps plex sonarr
cf ps plex grafana
# Filter by host
cf ps --host nuc
# Show status of a specific service
cf ps immich --service database
```
---
@@ -288,6 +388,7 @@ cf logs [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Show logs for all stacks |
| `--host, -H TEXT` | Filter to stacks on this host |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--follow, -f` | Follow logs (live stream) |
| `--tail, -n INTEGER` | Number of lines (default: 20 for --all, 100 otherwise) |
| `--config, -c PATH` | Path to config file |
@@ -302,10 +403,13 @@ cf logs plex
cf logs -f plex
# Show last 50 lines of multiple stacks
cf logs -n 50 plex sonarr
cf logs -n 50 plex grafana
# Show last 20 lines of all stacks
cf logs --all
# Show logs for a specific service
cf logs immich --service database
```
---
@@ -374,25 +478,31 @@ cf check jellyfin
Update local state from running stacks.
```bash
cf refresh [OPTIONS]
cf refresh [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Refresh all stacks |
| `--dry-run, -n` | Show what would change |
| `--log-path, -l PATH` | Path to Dockerfarm TOML log |
| `--config, -c PATH` | Path to config file |
Without arguments, refreshes all stacks (same as `--all`). With stack names, refreshes only those stacks.
**Examples:**
```bash
# Sync state with reality
# Sync state with reality (all stacks)
cf refresh
# Preview changes
cf refresh --dry-run
# Refresh specific stacks only
cf refresh plex sonarr
```
---

View File

@@ -42,8 +42,8 @@ hosts:
# Map stacks to the local host
stacks:
plex: local
sonarr: local
radarr: local
grafana: local
nextcloud: local
```
### Multi-host (full example)
@@ -69,8 +69,8 @@ hosts:
stacks:
# Single-host stacks
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
# Multi-host stacks
dozzle: all # Run on ALL hosts
@@ -94,7 +94,7 @@ compose_dir: /opt/compose
├── plex/
│ ├── docker-compose.yml # or compose.yaml
│ └── .env # optional environment file
├── sonarr/
├── grafana/
│ └── docker-compose.yml
└── ...
```
@@ -185,8 +185,8 @@ hosts:
```yaml
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
### Multi-Host Stack
@@ -229,7 +229,7 @@ For example, if your config is at `~/.config/compose-farm/compose-farm.yaml`, th
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
This file records which stacks are deployed and on which host.
@@ -373,8 +373,8 @@ hosts:
stacks:
# Media
plex: nuc
sonarr: nuc
radarr: nuc
jellyfin: nuc
immich: nuc
# Infrastructure
traefik: nuc
@@ -388,7 +388,6 @@ stacks:
```yaml
compose_dir: /opt/compose
network: production
traefik_file: /opt/traefik/dynamic.d/cf.yml
traefik_stack: traefik

View File

@@ -111,9 +111,9 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0
/opt/compose/ # compose_dir in config
├── plex/
│ └── docker-compose.yml
├── sonarr/
├── grafana/
│ └── docker-compose.yml
├── radarr/
├── nextcloud/
│ └── docker-compose.yml
└── jellyfin/
└── docker-compose.yml
@@ -150,8 +150,8 @@ hosts:
stacks:
plex: local
sonarr: local
radarr: local
grafana: local
nextcloud: local
```
#### Multi-host example
@@ -171,8 +171,8 @@ hosts:
# Map stacks to hosts
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
@@ -211,7 +211,7 @@ Starts all stacks on their assigned hosts.
### Start Specific Stacks
```bash
cf up plex sonarr
cf up plex grafana
```
### Apply Configuration
@@ -250,19 +250,22 @@ Create the compose file:
```bash
# On any host (shared storage)
mkdir -p /opt/compose/prowlarr
cat > /opt/compose/prowlarr/docker-compose.yml << 'EOF'
mkdir -p /opt/compose/gitea
cat > /opt/compose/gitea/docker-compose.yml << 'EOF'
services:
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
gitea:
image: docker.gitea.com/gitea:latest
container_name: gitea
environment:
- PUID=1000
- PGID=1000
- USER_UID=1000
- USER_GID=1000
volumes:
- /opt/config/prowlarr:/config
- /opt/config/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "9696:9696"
- "3000:3000"
- "2222:22"
restart: unless-stopped
EOF
```
@@ -272,13 +275,13 @@ Add to config:
```yaml
stacks:
# ... existing stacks
prowlarr: nuc
gitea: nuc
```
Start the stack:
```bash
cf up prowlarr
cf up gitea
```
### 2. Move a Stack to Another Host

View File

@@ -76,7 +76,7 @@ hosts:
stacks:
plex: server-1
jellyfin: server-2
sonarr: server-1
grafana: server-1
```
```bash
@@ -110,8 +110,8 @@ hosts:
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
See [Configuration](configuration.md) for all options and the full search order.
@@ -123,7 +123,7 @@ See [Configuration](configuration.md) for all options and the full search order.
cf apply
# Start specific stacks
cf up plex sonarr
cf up plex grafana
# Check status
cf ps

View File

@@ -27,8 +27,8 @@ hosts:
stacks:
plex: nuc
jellyfin: hp
sonarr: nuc
radarr: nuc
grafana: nuc
nextcloud: nuc
```
Then just:

View File

@@ -133,7 +133,7 @@ hosts:
stacks:
traefik: nuc # Traefik runs here
plex: hp # Routed via file-provider
sonarr: hp
grafana: hp
```
With `traefik_file` set, these commands auto-regenerate the config:
@@ -256,8 +256,8 @@ stacks:
traefik: nuc
plex: hp
jellyfin: nas
sonarr: nuc
radarr: nuc
grafana: nuc
nextcloud: nuc
```
### /opt/compose/plex/docker-compose.yml
@@ -309,7 +309,7 @@ http:
- url: http://192.168.1.100:8096
```
Note: `sonarr` and `radarr` are NOT in the file because they're on the same host as Traefik (`nuc`).
Note: `grafana` and `nextcloud` are NOT in the file because they're on the same host as Traefik (`nuc`).
## Combining with Existing Config

View File

@@ -116,7 +116,7 @@ The web UI requires additional dependencies:
pip install compose-farm[web]
# If installed via uv
uv tool install compose-farm --with web
uv tool install 'compose-farm[web]'
```
## Architecture

52
justfile Normal file
View File

@@ -0,0 +1,52 @@
# Compose Farm Development Commands
# Run `just` to see available commands
# Default: list available commands
default:
@just --list
# Install development dependencies
install:
uv sync --all-extras --dev
# Run all tests (no coverage for speed)
test:
uv run pytest --no-cov
# Run unit tests only (parallel, with coverage)
test-unit:
uv run pytest -m "not browser" -n auto
# Run browser tests only (sequential, no coverage)
test-browser:
uv run pytest -m browser --no-cov
# Lint, format, and type check
lint:
uv run ruff check --fix .
uv run ruff format .
uv run mypy src
uv run ty check src
# Start web UI in development mode with auto-reload
web:
uv run cf web --reload --port 9001
# Kill the web server
kill-web:
lsof -ti :9001 | xargs kill -9 2>/dev/null || true
# Build docs and serve locally
doc:
uvx zensical build
python -m http.server -d site 9002
# Kill the docs server
kill-doc:
lsof -ti :9002 | xargs kill -9 2>/dev/null || true
# Clean up build artifacts and caches
clean:
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true

View File

@@ -159,6 +159,14 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
# Get state
current_host = get_stack_host(config, name)
# Get service names from compose file
services: list[str] = []
if compose_content:
compose_data = yaml.safe_load(compose_content) or {}
raw_services = compose_data.get("services", {})
if isinstance(raw_services, dict):
services = list(raw_services.keys())
return templates.TemplateResponse(
"stack.html",
{
@@ -170,6 +178,7 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
"compose_path": str(compose_path) if compose_path else None,
"env_content": env_content,
"env_path": str(env_path) if env_path else None,
"services": services,
},
)

View File

@@ -57,6 +57,10 @@ const LANGUAGE_MAP = {
'env': 'plaintext'
};
// Detect Mac for keyboard shortcut display
const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const MOD_KEY = IS_MAC ? '⌘' : 'Ctrl';
// ============================================================================
// STATE
// ============================================================================
@@ -512,7 +516,7 @@ function playFabIntro() {
const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk'];
const THEME_KEY = 'cf_theme';
const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899' };
const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899', service: '#14b8a6' };
let commands = [];
let filtered = [];
let selected = 0;
@@ -583,6 +587,27 @@ function playFabIntro() {
stackCmd('Update', 'Pull + restart', 'update', icons.refresh_cw),
stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
);
// Add service-specific commands from data-services attribute
// Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services');
if (servicesAttr) {
const services = servicesAttr.split(',').filter(s => s).sort();
const svcCmd = (action, service, desc, endpoint, icon) =>
cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon);
const svcActions = [
['Logs', 'View logs for service', 'logs', icons.file_text],
['Pull', 'Pull image for service', 'pull', icons.cloud_download],
['Restart', 'Restart service', 'restart', icons.rotate_cw],
['Stop', 'Stop service', 'stop', icons.square],
['Up', 'Start service', 'up', icons.play],
];
for (const [action, desc, endpoint, icon] of svcActions) {
for (const service of services) {
actions.push(svcCmd(action, service, desc, endpoint, icon));
}
}
}
}
// Add nav commands for all stacks from sidebar
@@ -601,10 +626,21 @@ function playFabIntro() {
}
function filter() {
// 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));
// Fuzzy matching: all query words must match the START of a word in the command name
// Examples: "r ba" matches "Restart: bazarr" but NOT "Logs: bazarr"
const q = input.value.toLowerCase().trim();
// Split query into words and strip non-alphanumeric chars
const queryWords = q.split(/[^a-z0-9]+/).filter(w => w);
filtered = commands.filter(c => {
const name = c.name.toLowerCase();
// Split command name into words (split on non-alphanumeric)
const nameWords = name.split(/[^a-z0-9]+/).filter(w => w);
// Each query word must match the start of some word in the command name
return queryWords.every(qw =>
nameWords.some(nw => nw.startsWith(qw))
);
});
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
@@ -751,12 +787,26 @@ function initKeyboardShortcuts() {
});
}
/**
* Update keyboard shortcut display based on OS
* Replaces ⌘ with Ctrl on non-Mac platforms
*/
function updateShortcutKeys() {
// Update elements with class 'shortcut-key' that contain ⌘
document.querySelectorAll('.shortcut-key').forEach(el => {
if (el.textContent === '⌘') {
el.textContent = MOD_KEY;
}
});
}
/**
* Initialize page components
*/
function initPage() {
initMonacoEditors();
initSaveButton();
updateShortcutKeys();
}
/**

View File

@@ -15,7 +15,7 @@
<option value="{{ name }}">{{ name }}{% if name == local_host %} (local){% endif %}</option>
{% endfor %}
</select>
<button id="console-connect-btn" class="btn btn-sm btn-primary" onclick="connectConsole()">Connect</button>
<div class="tooltip" data-tip="Connect to host via SSH"><button id="console-connect-btn" class="btn btn-sm btn-primary" onclick="connectConsole()">Connect</button></div>
<span id="console-status" class="text-sm opacity-60"></span>
</div>
@@ -29,11 +29,11 @@
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-4">
<input type="text" id="console-file-path" class="input input-sm input-bordered w-96" placeholder="Enter file path (e.g., ~/docker-compose.yaml)" value="{{ config_path }}">
<button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button>
<div class="tooltip" data-tip="Load file from host"><button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button></div>
</div>
<div class="flex items-center gap-2">
<span id="editor-status" class="text-sm opacity-60"></span>
<button id="console-save-btn" class="btn btn-sm btn-primary" onclick="saveFile()">{{ save() }} Save</button>
<div class="tooltip" data-tip="Save file to host (⌘/Ctrl+S)"><button id="console-save-btn" class="btn btn-sm btn-primary" onclick="saveFile()">{{ save() }} Save</button></div>
</div>
</div>
<div id="console-editor" class="resize-y overflow-hidden rounded-lg" style="height: 512px; min-height: 200px;"></div>

View File

@@ -30,8 +30,8 @@
</dialog>
<!-- Floating button to open command palette -->
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘K)">
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘/Ctrl+K)">
<div class="cmd-fab-inner">
<span>⌘ + K</span>
<span class="shortcut-key"></span><span class="shortcut-plus"> + </span><span class="shortcut-key">K</span>
</div>
</button>

View File

@@ -4,7 +4,7 @@
{% block title %}{{ name }} - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-5xl">
<div class="max-w-5xl" data-services="{{ services | join(',') }}">
<div class="mb-6">
<h1 class="text-3xl font-bold rainbow-hover">{{ name }}</h1>
<div class="flex flex-wrap items-center gap-2 mt-2">

View File

@@ -212,22 +212,22 @@ def test_generate_follows_network_mode_service_for_ports(tmp_path: Path) -> None
"image": "gluetun",
"ports": ["5080:5080", "9696:9696"],
},
"qbittorrent": {
"image": "qbittorrent",
"syncthing": {
"image": "syncthing",
"network_mode": "service:vpn",
"labels": [
"traefik.enable=true",
"traefik.http.routers.torrent.rule=Host(`torrent.example.com`)",
"traefik.http.services.torrent.loadbalancer.server.port=5080",
"traefik.http.routers.sync.rule=Host(`sync.example.com`)",
"traefik.http.services.sync.loadbalancer.server.port=5080",
],
},
"prowlarr": {
"image": "prowlarr",
"searxng": {
"image": "searxng",
"network_mode": "service:vpn",
"labels": [
"traefik.enable=true",
"traefik.http.routers.prowlarr.rule=Host(`prowlarr.example.com`)",
"traefik.http.services.prowlarr.loadbalancer.server.port=9696",
"traefik.http.routers.searxng.rule=Host(`searxng.example.com`)",
"traefik.http.services.searxng.loadbalancer.server.port=9696",
],
},
}
@@ -238,10 +238,10 @@ def test_generate_follows_network_mode_service_for_ports(tmp_path: Path) -> None
assert warnings == []
# Both services should get their ports from the vpn service
torrent_servers = dynamic["http"]["services"]["torrent"]["loadbalancer"]["servers"]
assert torrent_servers == [{"url": "http://192.168.1.10:5080"}]
prowlarr_servers = dynamic["http"]["services"]["prowlarr"]["loadbalancer"]["servers"]
assert prowlarr_servers == [{"url": "http://192.168.1.10:9696"}]
sync_servers = dynamic["http"]["services"]["sync"]["loadbalancer"]["servers"]
assert sync_servers == [{"url": "http://192.168.1.10:5080"}]
searxng_servers = dynamic["http"]["services"]["searxng"]["loadbalancer"]["servers"]
assert searxng_servers == [{"url": "http://192.168.1.10:9696"}]
def test_parse_external_networks_single(tmp_path: Path) -> None:

View File

@@ -31,12 +31,12 @@ services:
(plex_dir / ".env").write_text("PLEX_CLAIM=claim-xxx\n")
# Create another stack
sonarr_dir = compose_path / "sonarr"
sonarr_dir.mkdir()
(sonarr_dir / "compose.yaml").write_text("""
grafana_dir = compose_path / "grafana"
grafana_dir.mkdir()
(grafana_dir / "compose.yaml").write_text("""
services:
sonarr:
image: linuxserver/sonarr
grafana:
image: grafana/grafana
""")
return compose_path
@@ -58,7 +58,7 @@ hosts:
stacks:
plex: server-1
sonarr: server-2
grafana: server-2
""")
# State file must be alongside config file

View File

@@ -110,18 +110,25 @@ def test_config(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Create test config and compose files.
Creates a multi-host, multi-stack config for comprehensive testing:
- server-1: plex (running), sonarr (not started)
- server-2: radarr (running), jellyfin (not started)
- server-1: plex (running), grafana (not started)
- server-2: nextcloud (running), jellyfin (not started)
"""
tmp: Path = tmp_path_factory.mktemp("data")
# Create compose dir with stacks
compose_dir = tmp / "compose"
compose_dir.mkdir()
for name in ["plex", "sonarr", "radarr", "jellyfin"]:
for name in ["plex", "grafana", "nextcloud", "jellyfin"]:
svc = compose_dir / name
svc.mkdir()
(svc / "compose.yaml").write_text(f"services:\n {name}:\n image: test/{name}\n")
if name == "plex":
# Multi-service stack for testing service commands
# Includes hyphenated name (plex-server) to test word-boundary matching
(svc / "compose.yaml").write_text(
"services:\n plex-server:\n image: test/plex\n redis:\n image: redis:alpine\n"
)
else:
(svc / "compose.yaml").write_text(f"services:\n {name}:\n image: test/{name}\n")
# Create config with multiple hosts
config = tmp / "compose-farm.yaml"
@@ -136,14 +143,14 @@ hosts:
user: docker
stacks:
plex: server-1
sonarr: server-1
radarr: server-2
grafana: server-1
nextcloud: server-2
jellyfin: server-2
""")
# Create state (plex and radarr running, sonarr and jellyfin not started)
# Create state (plex and nextcloud running, grafana and jellyfin not started)
(tmp / "compose-farm-state.yaml").write_text(
"deployed:\n plex: server-1\n radarr: server-2\n"
"deployed:\n plex: server-1\n nextcloud: server-2\n"
)
return config
@@ -233,13 +240,13 @@ class TestHTMXSidebarLoading:
# Verify actual stacks from test config appear
stacks = page.locator("#sidebar-stacks li")
assert stacks.count() == 4 # plex, sonarr, radarr, jellyfin
assert stacks.count() == 4 # plex, grafana, nextcloud, jellyfin
# Check specific stacks are present
content = page.locator("#sidebar-stacks").inner_text()
assert "plex" in content
assert "sonarr" in content
assert "radarr" in content
assert "grafana" in content
assert "nextcloud" in content
assert "jellyfin" in content
def test_dashboard_content_persists_after_sidebar_loads(
@@ -268,15 +275,15 @@ class TestHTMXSidebarLoading:
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=5000)
# plex and radarr are in state (running) - should have success status
# plex and nextcloud are in state (running) - should have success status
plex_item = page.locator("#sidebar-stacks li", has_text="plex")
assert plex_item.locator(".status-success").count() == 1
radarr_item = page.locator("#sidebar-stacks li", has_text="radarr")
assert radarr_item.locator(".status-success").count() == 1
nextcloud_item = page.locator("#sidebar-stacks li", has_text="nextcloud")
assert nextcloud_item.locator(".status-success").count() == 1
# sonarr and jellyfin are NOT in state (not started) - should have neutral status
sonarr_item = page.locator("#sidebar-stacks li", has_text="sonarr")
assert sonarr_item.locator(".status-neutral").count() == 1
# grafana and jellyfin are NOT in state (not started) - should have neutral status
grafana_item = page.locator("#sidebar-stacks li", has_text="grafana")
assert grafana_item.locator(".status-neutral").count() == 1
jellyfin_item = page.locator("#sidebar-stacks li", has_text="jellyfin")
assert jellyfin_item.locator(".status-neutral").count() == 1
@@ -334,20 +341,20 @@ class TestDashboardContent:
stats = page.locator("#stats-cards").inner_text()
# From test config: 2 hosts, 4 stacks, 2 running (plex, radarr)
# From test config: 2 hosts, 4 stacks, 2 running (plex, nextcloud)
assert "2" in stats # hosts count
assert "4" in stats # stacks count
def test_pending_shows_not_started_stacks(self, page: Page, server_url: str) -> None:
"""Pending operations shows sonarr and jellyfin as not started."""
"""Pending operations shows grafana and jellyfin as not started."""
page.goto(server_url)
page.wait_for_selector("#pending-operations", timeout=5000)
pending = page.locator("#pending-operations")
content = pending.inner_text().lower()
# sonarr and jellyfin are not in state, should show as not started
assert "sonarr" in content or "not started" in content
# grafana and jellyfin are not in state, should show as not started
assert "grafana" in content or "not started" in content
assert "jellyfin" in content or "not started" in content
def test_dashboard_monaco_loads(self, page: Page, server_url: str) -> None:
@@ -485,8 +492,8 @@ class TestSidebarFilter:
count_badge = page.locator("#sidebar-count")
assert "(4)" in count_badge.inner_text()
# Filter to show only stacks containing "arr" (sonarr, radarr)
self._filter_sidebar(page, "arr")
# Filter to show only stacks containing "x" (plex, nextcloud)
self._filter_sidebar(page, "x")
# Count should update to (2)
assert "(2)" in count_badge.inner_text()
@@ -512,14 +519,14 @@ class TestSidebarFilter:
# Select server-1 from dropdown
page.locator("#sidebar-host-select").select_option("server-1")
# Only plex and sonarr (server-1 stacks) should be visible
# Only plex and grafana (server-1 stacks) should be visible
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 2
content = visible.all_inner_texts()
assert any("plex" in s for s in content)
assert any("sonarr" in s for s in content)
assert not any("radarr" in s for s in content)
assert any("grafana" in s for s in content)
assert not any("nextcloud" in s for s in content)
assert not any("jellyfin" in s for s in content)
def test_combined_text_and_host_filter(self, page: Page, server_url: str) -> None:
@@ -530,12 +537,12 @@ class TestSidebarFilter:
# Filter by server-2 host
page.locator("#sidebar-host-select").select_option("server-2")
# Then filter by text "arr" (should match only radarr on server-2)
self._filter_sidebar(page, "arr")
# Then filter by text "next" (should match only nextcloud on server-2)
self._filter_sidebar(page, "next")
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 1
assert "radarr" in visible.first.inner_text()
assert "nextcloud" in visible.first.inner_text()
def test_clearing_filter_shows_all_stacks(self, page: Page, server_url: str) -> None:
"""Clearing filter restores all stacks."""
@@ -606,7 +613,7 @@ class TestCommandPalette:
cmd_list = page.locator("#cmd-list").inner_text()
# Stacks should appear as navigation options
assert "plex" in cmd_list
assert "radarr" in cmd_list
assert "nextcloud" in cmd_list
def test_palette_filters_on_input(self, page: Page, server_url: str) -> None:
"""Typing in palette filters the command list."""
@@ -1617,6 +1624,253 @@ class TestServicePagePalette:
assert len(api_calls) >= 1
assert "/api/apply" in api_calls[0]
def test_palette_shows_service_commands(self, page: Page, server_url: str) -> None:
"""Command palette on stack page shows service-specific commands."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=5000)
# Navigate to plex stack (has plex and redis services)
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Filter to service commands
page.locator("#cmd-input").fill("Restart:")
cmd_list = page.locator("#cmd-list").inner_text()
# Should show restart commands for both services
assert "Restart: plex-server" in cmd_list
assert "Restart: redis" in cmd_list
def test_palette_service_commands_for_all_actions(self, page: Page, server_url: str) -> None:
"""Service commands include all expected actions (restart, pull, logs, stop, up)."""
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)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Check all service action types exist for the plex-server service
actions = ["Restart", "Pull", "Logs", "Stop", "Up"]
for action in actions:
page.locator("#cmd-input").fill(f"{action}: plex-server")
cmd_list = page.locator("#cmd-list").inner_text()
assert f"{action}: plex-server" in cmd_list, f"Missing {action}: plex-server command"
def test_palette_service_command_triggers_api(self, page: Page, server_url: str) -> None:
"""Selecting service command triggers correct service API endpoint."""
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)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "svc-test", "stack": "plex", "service": "redis", "command": "restart"}',
)
page.route("**/api/stack/plex/service/redis/restart", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Filter to Restart:redis and execute
page.locator("#cmd-input").fill("Restart: redis")
page.keyboard.press("Enter")
# Wait for API call
page.wait_for_timeout(500)
# Verify correct service API was called
assert len(api_calls) >= 1
assert "/api/stack/plex/service/redis/restart" in api_calls[0]
def test_palette_service_commands_have_teal_indicator(
self, page: Page, server_url: str
) -> None:
"""Service commands display with teal color indicator."""
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)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Filter to a service command
page.locator("#cmd-input").fill("Restart: plex-server")
# Get the command element and check its border color
cmd_item = page.locator("#cmd-list a", has_text="Restart: plex-server").first
style = cmd_item.get_attribute("style") or ""
# Service commands should have teal color (#14b8a6)
assert "#14b8a6" in style, f"Expected teal border color, got style: {style}"
def test_single_service_stack_shows_service_commands(self, page: Page, server_url: str) -> None:
"""Single-service stacks also show service commands."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks li", timeout=5000)
# Navigate to sonarr stack (has only sonarr service)
sonarr_link = page.locator("#sidebar-stacks a", has_text="sonarr")
sonarr_link.wait_for(timeout=5000)
sonarr_link.click()
page.wait_for_url("**/stack/sonarr", timeout=5000)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Filter to service commands
page.locator("#cmd-input").fill("Restart:")
cmd_list = page.locator("#cmd-list").inner_text()
# Should show restart command for sonarr service
assert "Restart: sonarr" in cmd_list
def test_palette_filter_without_colon(self, page: Page, server_url: str) -> None:
"""Filter matches service commands without colon (e.g., 'Up redis' matches 'Up: redis')."""
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)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type "Restart redis" without colon
page.locator("#cmd-input").fill("Restart redis")
cmd_list = page.locator("#cmd-list").inner_text()
# Should still match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_fuzzy_filter_partial_words(self, page: Page, server_url: str) -> None:
"""Filter matches with partial words (e.g., 'rest red' matches 'Restart: redis')."""
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)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type partial words "rest red"
page.locator("#cmd-input").fill("rest red")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_fuzzy_filter_any_order(self, page: Page, server_url: str) -> None:
"""Filter matches words in any order (e.g., 'redis rest' matches 'Restart: redis')."""
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)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type words in reverse order "redis rest"
page.locator("#cmd-input").fill("redis rest")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: redis"
assert "Restart: redis" in cmd_list
def test_palette_filter_without_colon_triggers_api(self, page: Page, server_url: str) -> None:
"""Service command filtered without colon still triggers correct API."""
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)
# Track API calls
api_calls: list[str] = []
def handle_route(route: Route) -> None:
api_calls.append(route.request.url)
route.fulfill(
status=200,
content_type="application/json",
body='{"task_id": "test", "stack": "plex", "service": "redis", "command": "pull"}',
)
page.route("**/api/stack/plex/service/redis/pull", handle_route)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type "Pull redis" without colon and execute
page.locator("#cmd-input").fill("Pull redis")
page.keyboard.press("Enter")
# Wait for API call
page.wait_for_timeout(500)
# Verify correct service API was called
assert len(api_calls) >= 1
assert "/api/stack/plex/service/redis/pull" in api_calls[0]
def test_palette_hyphenated_service_name(self, page: Page, server_url: str) -> None:
"""Filter matches hyphenated service names by second word (e.g., 'server' matches 'plex-server')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks a", timeout=5000)
# Navigate to plex stack (has plex-server service)
page.locator("#sidebar-stacks a", has_text="plex").click()
page.wait_for_url("**/stack/plex", timeout=5000)
# Open command palette
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type just "server" - should match "plex-server" because hyphen splits words
page.locator("#cmd-input").fill("Restart server")
cmd_list = page.locator("#cmd-list").inner_text()
# Should match "Restart: plex-server"
assert "Restart: plex-server" in cmd_list
# Also verify "rest plex" matches via the first part of hyphenated name
page.locator("#cmd-input").fill("rest plex")
cmd_list = page.locator("#cmd-list").inner_text()
assert "Restart: plex-server" in cmd_list
class TestThemeSwitcher:
"""Test theme switcher via command palette."""
@@ -1734,6 +1988,22 @@ class TestThemeSwitcher:
assert "theme: light" in cmd_list
assert "theme: dark" in cmd_list
def test_theme_filter_without_colon(self, page: Page, server_url: str) -> None:
"""Filter matches theme commands without colon (e.g., 'theme dark' matches 'theme: dark')."""
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=5000)
# Open with Cmd+K
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
# Type "theme dark" without colon
page.locator("#cmd-input").fill("theme dark")
# Should show theme: dark option
cmd_list = page.locator("#cmd-list").inner_text()
assert "theme: dark" in cmd_list
def test_theme_command_opens_theme_picker(self, page: Page, server_url: str) -> None:
"""Selecting 'Theme' command reopens palette with theme filter."""
page.goto(server_url)