mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc54e89b33 | ||
|
|
f71e5cffd6 | ||
|
|
0e32729763 | ||
|
|
b0b501fa98 | ||
|
|
7e00596046 | ||
|
|
d1e4d9b05c | ||
|
|
3fbae630f9 | ||
|
|
3e3c919714 | ||
|
|
59b797a89d | ||
|
|
7caf006e07 | ||
|
|
45040b75f1 | ||
|
|
fa1c5c1044 | ||
|
|
67e832f687 | ||
|
|
da986fab6a | ||
|
|
5dd6e2ca05 | ||
|
|
16435065de |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ compose-farm.yaml
|
||||
coverage.xml
|
||||
.env
|
||||
homepage/
|
||||
site/
|
||||
|
||||
@@ -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:
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -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)
|
||||
@@ -116,10 +131,12 @@ CLI available as `cf` or `compose-farm`.
|
||||
|---------|-------------|
|
||||
| `up` | Start stacks (`docker compose up -d`), auto-migrates if host changed |
|
||||
| `down` | Stop stacks (`docker compose down`). Use `--orphaned` to stop stacks removed from config |
|
||||
| `stop` | Stop services without removing containers (`docker compose stop`) |
|
||||
| `pull` | Pull latest images |
|
||||
| `restart` | `down` + `up -d` |
|
||||
| `update` | `pull` + `build` + `down` + `up -d` |
|
||||
| `apply` | Make reality match config: migrate stacks + stop orphans. Use `--dry-run` to preview |
|
||||
| `compose` | Run any docker compose command on a stack (passthrough) |
|
||||
| `logs` | Show stack logs |
|
||||
| `ps` | Show status of all stacks |
|
||||
| `stats` | Show overview (hosts, stacks, pending migrations; `--live` for container counts) |
|
||||
|
||||
203
README.md
203
README.md
@@ -155,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
|
||||
@@ -237,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
|
||||
|
||||
@@ -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
|
||||
@@ -332,7 +332,8 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
|
||||
|---------|-------------|
|
||||
| **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** |
|
||||
| `cf up <stack>` | Start stack (auto-migrates if host changed) |
|
||||
| `cf down <stack>` | Stop stack |
|
||||
| `cf down <stack>` | Stop and remove stack containers |
|
||||
| `cf stop <stack>` | Stop stack without removing containers |
|
||||
| `cf restart <stack>` | down + up |
|
||||
| `cf update <stack>` | pull + build + down + up |
|
||||
| `cf pull <stack>` | Pull latest images |
|
||||
@@ -425,10 +426,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ up Start stacks (docker compose up -d). Auto-migrates if host │
|
||||
│ changed. │
|
||||
│ down Stop stacks (docker compose down). │
|
||||
│ stop Stop services without removing containers (docker compose │
|
||||
│ stop). │
|
||||
│ pull Pull latest images (docker compose pull). │
|
||||
│ restart Restart stacks (down + up). │
|
||||
│ update Update stacks (pull + build + down + up). │
|
||||
│ restart Restart stacks (down + up). With --service, restarts just │
|
||||
│ that service. │
|
||||
│ update Update stacks (pull + build + down + up). With --service, │
|
||||
│ updates just that service. │
|
||||
│ apply Make reality match config (start, migrate, stop as needed). │
|
||||
│ compose Run any docker compose command on a stack. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Configuration ──────────────────────────────────────────────────────────────╮
|
||||
│ traefik-file Generate a Traefik file-provider fragment from compose │
|
||||
@@ -440,7 +446,8 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ ssh Manage SSH keys for passwordless authentication. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
|
||||
│ logs Show stack logs. │
|
||||
│ logs Show stack logs. With --service, shows logs for just that │
|
||||
│ service. │
|
||||
│ ps Show status of stacks. │
|
||||
│ stats Show overview statistics for hosts and stacks. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -479,10 +486,11 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on 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 │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -528,6 +536,41 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf stop --help</code></summary>
|
||||
|
||||
<!-- CODE:BASH:START -->
|
||||
<!-- echo '```yaml' -->
|
||||
<!-- export NO_COLOR=1 -->
|
||||
<!-- export TERM=dumb -->
|
||||
<!-- export TERMINAL_WIDTH=90 -->
|
||||
<!-- cf stop --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf stop [OPTIONS] [STACKS]...
|
||||
|
||||
Stop services without removing containers (docker compose stop).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf pull --help</code></summary>
|
||||
|
||||
@@ -551,9 +594,10 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -579,15 +623,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Usage: cf restart [OPTIONS] [STACKS]...
|
||||
|
||||
Restart stacks (down + up).
|
||||
Restart stacks (down + up). With --service, restarts just that service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -613,15 +658,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Usage: cf update [OPTIONS] [STACKS]...
|
||||
|
||||
Update stacks (pull + build + down + up).
|
||||
Update stacks (pull + build + down + up). With --service, updates just that
|
||||
service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -675,6 +722,53 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf compose --help</code></summary>
|
||||
|
||||
<!-- CODE:BASH:START -->
|
||||
<!-- echo '```yaml' -->
|
||||
<!-- export NO_COLOR=1 -->
|
||||
<!-- export TERM=dumb -->
|
||||
<!-- export TERMINAL_WIDTH=90 -->
|
||||
<!-- cf compose --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf compose [OPTIONS] STACK COMMAND [ARGS]...
|
||||
|
||||
Run any docker compose command on a stack.
|
||||
|
||||
Passthrough to docker compose for commands not wrapped by cf.
|
||||
Options after COMMAND are passed to docker compose, not cf.
|
||||
|
||||
Examples:
|
||||
cf compose mystack --help - show docker compose help
|
||||
cf compose mystack top - view running processes
|
||||
cf compose mystack images - list images
|
||||
cf compose mystack exec web bash - interactive shell
|
||||
cf compose mystack config - view parsed config
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ * stack TEXT Stack to operate on (use '.' for current dir) │
|
||||
│ [required] │
|
||||
│ * command TEXT Docker compose command [required] │
|
||||
│ args [ARGS]... Additional arguments │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
**Configuration**
|
||||
|
||||
<details>
|
||||
@@ -890,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>
|
||||
|
||||
@@ -912,19 +1026,20 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Usage: cf logs [OPTIONS] [STACKS]...
|
||||
|
||||
Show stack logs.
|
||||
Show stack logs. With --service, shows logs for just that service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --follow -f Follow logs │
|
||||
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
|
||||
│ otherwise) │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on 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 │
|
||||
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
|
||||
│ otherwise) │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -956,15 +1071,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
Without arguments: shows all stacks (same as --all).
|
||||
With stack names: shows only those stacks.
|
||||
With --host: shows stacks on that host.
|
||||
With --service: filters to a specific service within the stack.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on 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 │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -1021,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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
130
docs/commands.md
130
docs/commands.md
@@ -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 |
|
||||
@@ -43,7 +45,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#t=0.001" type="video/webm">
|
||||
<source src="/assets/apply.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
@@ -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,16 +216,19 @@ 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/update.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -275,7 +375,7 @@ cf ps --host nuc
|
||||
Show stack logs.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/logs.webm#t=0.001" type="video/webm">
|
||||
<source src="/assets/logs.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,13 +18,13 @@ Before you begin, ensure you have:
|
||||
## Installation
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/install.webm#t=0.001" type="video/webm">
|
||||
<source src="/assets/install.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -136,8 +150,8 @@ hosts:
|
||||
|
||||
stacks:
|
||||
plex: local
|
||||
sonarr: local
|
||||
radarr: local
|
||||
grafana: local
|
||||
nextcloud: local
|
||||
```
|
||||
|
||||
#### Multi-host example
|
||||
@@ -157,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.
|
||||
@@ -197,7 +211,7 @@ Starts all stacks on their assigned hosts.
|
||||
### Start Specific Stacks
|
||||
|
||||
```bash
|
||||
cf up plex sonarr
|
||||
cf up plex grafana
|
||||
```
|
||||
|
||||
### Apply Configuration
|
||||
@@ -236,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
|
||||
```
|
||||
@@ -258,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
|
||||
|
||||
@@ -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#t=0.001" type="video/webm">
|
||||
<source src="/assets/quickstart.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
**[Web UI](web-ui.md):**
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-workflow.webm#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-workflow.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
## Why Compose Farm?
|
||||
@@ -76,7 +76,7 @@ hosts:
|
||||
stacks:
|
||||
plex: server-1
|
||||
jellyfin: server-2
|
||||
sonarr: server-1
|
||||
grafana: server-1
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -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
|
||||
@@ -110,10 +110,12 @@ hosts:
|
||||
|
||||
stacks:
|
||||
plex: nuc
|
||||
sonarr: nuc
|
||||
radarr: hp
|
||||
grafana: nuc
|
||||
nextcloud: hp
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for all options and the full search order.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
@@ -121,7 +123,7 @@ stacks:
|
||||
cf apply
|
||||
|
||||
# Start specific stacks
|
||||
cf up plex sonarr
|
||||
cf up plex grafana
|
||||
|
||||
# Check status
|
||||
cf ps
|
||||
@@ -136,7 +138,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#t=0.001" type="video/webm">
|
||||
<source src="/assets/migration.webm" type="video/webm">
|
||||
</video>
|
||||
- **Parallel execution**: Multiple stacks start/stop concurrently
|
||||
- **State tracking**: Knows which stacks are running where
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
@@ -27,8 +27,8 @@ hosts:
|
||||
stacks:
|
||||
plex: nuc
|
||||
jellyfin: hp
|
||||
sonarr: nuc
|
||||
radarr: nuc
|
||||
grafana: nuc
|
||||
nextcloud: nuc
|
||||
```
|
||||
|
||||
Then just:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-workflow.webm" 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-stack.webm" 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-themes.webm" 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-navigation.webm" 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-console.webm" 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#t=0.001" type="video/webm">
|
||||
<source src="/assets/web-shell.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
@@ -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
52
justfile
Normal 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
|
||||
@@ -23,6 +23,7 @@ app = typer.Typer(
|
||||
help="Compose Farm - run docker compose commands across multiple hosts",
|
||||
no_args_is_help=True,
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ HostOption = Annotated[
|
||||
str | None,
|
||||
typer.Option("--host", "-H", help="Filter to stacks on this host"),
|
||||
]
|
||||
ServiceOption = Annotated[
|
||||
str | None,
|
||||
typer.Option("--service", "-s", help="Target a specific service within the stack"),
|
||||
]
|
||||
|
||||
# --- Constants (internal) ---
|
||||
_MISSING_PATH_PREVIEW_LIMIT = 2
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
@@ -11,6 +12,7 @@ from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
ConfigOption,
|
||||
HostOption,
|
||||
ServiceOption,
|
||||
StacksArg,
|
||||
format_host,
|
||||
get_stacks,
|
||||
@@ -18,9 +20,11 @@ from compose_farm.cli.common import (
|
||||
maybe_regenerate_traefik,
|
||||
report_results,
|
||||
run_async,
|
||||
validate_host_for_stack,
|
||||
validate_stacks,
|
||||
)
|
||||
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
|
||||
from compose_farm.executor import run_on_stacks, run_sequential_on_stacks
|
||||
from compose_farm.executor import run_compose_on_host, run_on_stacks, run_sequential_on_stacks
|
||||
from compose_farm.operations import stop_orphaned_stacks, up_stacks
|
||||
from compose_farm.state import (
|
||||
get_orphaned_stacks,
|
||||
@@ -36,11 +40,19 @@ def up(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
|
||||
results = run_async(up_stacks(cfg, stack_list, raw=True))
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level up, use run_on_stacks directly (no migration logic)
|
||||
results = run_async(run_on_stacks(cfg, stack_list, f"up -d {service}", raw=True))
|
||||
else:
|
||||
results = run_async(up_stacks(cfg, stack_list, raw=True))
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
@@ -98,16 +110,39 @@ def down(
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def stop(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Stop services without removing containers (docker compose stop)."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"stop {service}" if service else "stop"
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def pull(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Pull latest images (docker compose pull)."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"pull {service}" if service else "pull"
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, "pull", raw=raw))
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@@ -115,12 +150,21 @@ def pull(
|
||||
def restart(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Restart stacks (down + up)."""
|
||||
"""Restart stacks (down + up). With --service, restarts just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level restart, use docker compose restart (more efficient)
|
||||
raw = True
|
||||
results = run_async(run_on_stacks(cfg, stack_list, f"restart {service}", raw=raw))
|
||||
else:
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
@@ -129,16 +173,37 @@ def restart(
|
||||
def update(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Update stacks (pull + build + down + up)."""
|
||||
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(
|
||||
run_sequential_on_stacks(
|
||||
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level update: pull + build + stop + up (stop instead of down)
|
||||
raw = True
|
||||
results = run_async(
|
||||
run_sequential_on_stacks(
|
||||
cfg,
|
||||
stack_list,
|
||||
[
|
||||
f"pull --ignore-buildable {service}",
|
||||
f"build {service}",
|
||||
f"stop {service}",
|
||||
f"up -d {service}",
|
||||
],
|
||||
raw=raw,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(
|
||||
run_sequential_on_stacks(
|
||||
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
|
||||
)
|
||||
)
|
||||
)
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
@@ -247,5 +312,62 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
report_results(all_results)
|
||||
|
||||
|
||||
@app.command(
|
||||
rich_help_panel="Lifecycle",
|
||||
context_settings={"allow_interspersed_args": False},
|
||||
)
|
||||
def compose(
|
||||
stack: Annotated[str, typer.Argument(help="Stack to operate on (use '.' for current dir)")],
|
||||
command: Annotated[str, typer.Argument(help="Docker compose command")],
|
||||
args: Annotated[list[str] | None, typer.Argument(help="Additional arguments")] = None,
|
||||
host: HostOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Run any docker compose command on a stack.
|
||||
|
||||
Passthrough to docker compose for commands not wrapped by cf.
|
||||
Options after COMMAND are passed to docker compose, not cf.
|
||||
|
||||
Examples:
|
||||
cf compose mystack --help - show docker compose help
|
||||
cf compose mystack top - view running processes
|
||||
cf compose mystack images - list images
|
||||
cf compose mystack exec web bash - interactive shell
|
||||
cf compose mystack config - view parsed config
|
||||
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Resolve "." to current directory name
|
||||
resolved_stack = Path.cwd().name if stack == "." else stack
|
||||
validate_stacks(cfg, [resolved_stack])
|
||||
|
||||
# Handle multi-host stacks
|
||||
hosts = cfg.get_hosts(resolved_stack)
|
||||
if len(hosts) > 1:
|
||||
if host is None:
|
||||
print_error(
|
||||
f"Stack [cyan]{resolved_stack}[/] runs on multiple hosts: {', '.join(hosts)}\n"
|
||||
f"Use [bold]--host[/] to specify which host"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
validate_host_for_stack(cfg, resolved_stack, host)
|
||||
target_host = host
|
||||
else:
|
||||
target_host = hosts[0]
|
||||
|
||||
# Build the full compose command
|
||||
full_cmd = command
|
||||
if args:
|
||||
full_cmd += " " + " ".join(args)
|
||||
|
||||
# Run with raw=True for proper TTY handling (progress bars, interactive)
|
||||
result = run_async(run_compose_on_host(cfg, resolved_stack, target_host, full_cmd, raw=True))
|
||||
print() # Ensure newline after raw output
|
||||
|
||||
if not result.success:
|
||||
raise typer.Exit(result.exit_code)
|
||||
|
||||
|
||||
# Alias: cf a = cf apply
|
||||
app.command("a", hidden=True)(apply)
|
||||
|
||||
@@ -14,6 +14,7 @@ from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
ConfigOption,
|
||||
HostOption,
|
||||
ServiceOption,
|
||||
StacksArg,
|
||||
get_stacks,
|
||||
load_config_or_exit,
|
||||
@@ -21,7 +22,7 @@ from compose_farm.cli.common import (
|
||||
run_async,
|
||||
run_parallel_with_progress,
|
||||
)
|
||||
from compose_farm.console import console
|
||||
from compose_farm.console import console, print_error
|
||||
from compose_farm.executor import run_command, run_on_stacks
|
||||
from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state
|
||||
|
||||
@@ -118,6 +119,7 @@ def logs(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
|
||||
tail: Annotated[
|
||||
int | None,
|
||||
@@ -125,8 +127,11 @@ def logs(
|
||||
] = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show stack logs."""
|
||||
"""Show stack logs. With --service, shows logs for just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Default to fewer lines when showing multiple stacks
|
||||
many_stacks = all_stacks or host is not None or len(stack_list) > 1
|
||||
@@ -134,6 +139,8 @@ def logs(
|
||||
cmd = f"logs --tail {effective_tail}"
|
||||
if follow:
|
||||
cmd += " -f"
|
||||
if service:
|
||||
cmd += f" {service}"
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd))
|
||||
report_results(results)
|
||||
|
||||
@@ -143,6 +150,7 @@ def ps(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show status of stacks.
|
||||
@@ -150,9 +158,14 @@ def ps(
|
||||
Without arguments: shows all stacks (same as --all).
|
||||
With stack names: shows only those stacks.
|
||||
With --host: shows stacks on that host.
|
||||
With --service: filters to a specific service within the stack.
|
||||
"""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True)
|
||||
results = run_async(run_on_stacks(cfg, stack_list, "ps"))
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"ps {service}" if service else "ps"
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd))
|
||||
report_results(results)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,12 +33,15 @@ def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str
|
||||
|
||||
|
||||
# Allowed stack commands
|
||||
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs"}
|
||||
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs", "stop"}
|
||||
|
||||
# Allowed service-level commands (no 'down' - use 'stop' for individual services)
|
||||
ALLOWED_SERVICE_COMMANDS = {"logs", "pull", "restart", "up", "stop"}
|
||||
|
||||
|
||||
@router.post("/stack/{name}/{command}")
|
||||
async def stack_action(name: str, command: str) -> dict[str, Any]:
|
||||
"""Run a compose command for a stack (up, down, restart, pull, update, logs)."""
|
||||
"""Run a compose command for a stack (up, down, restart, pull, update, logs, stop)."""
|
||||
if command not in ALLOWED_COMMANDS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
|
||||
|
||||
@@ -50,6 +53,23 @@ async def stack_action(name: str, command: str) -> dict[str, Any]:
|
||||
return {"task_id": task_id, "stack": name, "command": command}
|
||||
|
||||
|
||||
@router.post("/stack/{name}/service/{service}/{command}")
|
||||
async def service_action(name: str, service: str, command: str) -> dict[str, Any]:
|
||||
"""Run a compose command for a specific service within a stack."""
|
||||
if command not in ALLOWED_SERVICE_COMMANDS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
|
||||
|
||||
config = get_config()
|
||||
if name not in config.stacks:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
|
||||
|
||||
# Use --service flag to target specific service
|
||||
task_id = _start_task(
|
||||
lambda tid: run_compose_streaming(config, name, f"{command} --service {service}", tid)
|
||||
)
|
||||
return {"task_id": task_id, "stack": name, "service": service, "command": command}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_all() -> dict[str, Any]:
|
||||
"""Run cf apply to reconcile all stacks."""
|
||||
@@ -64,3 +84,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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +626,21 @@ function playFabIntro() {
|
||||
}
|
||||
|
||||
function filter() {
|
||||
const q = input.value.toLowerCase();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -634,7 +672,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;
|
||||
}
|
||||
@@ -749,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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{# Container list for a stack on a single host #}
|
||||
{% from "partials/icons.html" import terminal %}
|
||||
{% from "partials/icons.html" import terminal, rotate_ccw, scroll_text, square, play, cloud_download %}
|
||||
{% macro container_row(stack, container, host) %}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if container.State == "running" %}
|
||||
@@ -18,10 +18,49 @@
|
||||
<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="join">
|
||||
<div class="tooltip tooltip-top" data-tip="View logs">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/logs"
|
||||
hx-swap="none">
|
||||
{{ scroll_text() }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top" data-tip="Restart service">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/restart"
|
||||
hx-swap="none">
|
||||
{{ rotate_ccw() }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top" data-tip="Pull image">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/pull"
|
||||
hx-swap="none">
|
||||
{{ cloud_download() }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top" data-tip="Start service">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/up"
|
||||
hx-swap="none">
|
||||
{{ play() }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top" data-tip="Stop service">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/stop"
|
||||
hx-swap="none">
|
||||
{{ square() }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top" data-tip="Open shell">
|
||||
<button class="btn btn-sm btn-outline join-item"
|
||||
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
|
||||
{{ terminal() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -43,6 +43,18 @@
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro rotate_ccw(size=16) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro scroll_text(size=16) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro download(size=16) %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>
|
||||
|
||||
@@ -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">
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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