Compare commits

...

19 Commits

Author SHA1 Message Date
Bas Nijholt
2af48b2642 feat(web): add Glances integration for host resource stats (#124) 2025-12-28 08:37:57 +01:00
Bas Nijholt
f69993eac8 web: Rename command palette entry to "GitHub Repo" (#134)
Makes the entry searchable by typing "github" in the command palette.
2025-12-28 07:06:32 +01:00
Bas Nijholt
9bdcd143cf Prioritize dedicated SSH key over agent (#133) 2025-12-24 22:34:53 -08:00
Bas Nijholt
9230e12eb0 fix: Make SSH agent socket optional in docker-compose.yml (#132) 2025-12-24 12:22:01 -08:00
Bas Nijholt
2a923e6e81 fix: Include field name in config validation error messages (#131)
Previously, Pydantic validation errors like "Extra inputs are not
permitted" didn't show which field caused the error. Now the error
message includes the field location (e.g., "unknown_key: Extra inputs
are not permitted").
2025-12-22 22:35:19 -08:00
Bas Nijholt
5f2e081298 perf: Batch snapshot collection to 1 SSH call per host (#130)
## Summary

Optimize `cf refresh` SSH calls from O(stacks) to O(hosts):
- Discovery: 1 SSH call per host (unchanged)
- Snapshots: 1 SSH call per host (was 1 per stack)

For 50 stacks across 4 hosts: 54 → 8 SSH calls.

## Changes

**Performance:**
- Use `docker ps` + `docker image inspect` instead of `docker compose images` per stack
- Batch snapshot collection by host in `collect_stacks_entries_on_host()`

**Architecture:**
- Add `build_discovery_results()` to `operations.py` (business logic)
- Keep progress bar wrapper in `cli/management.py` (presentation)
- Remove dead code: `discover_all_stacks_on_all_hosts()`, `collect_all_stacks_entries()`
2025-12-22 22:19:32 -08:00
Bas Nijholt
6fbc7430cb perf: Optimize stray detection to use 1 SSH call per host (#129)
* perf: Optimize stray detection to use 1 SSH call per host

Previously, stray detection checked each stack on each host individually,
resulting in (stacks * hosts) SSH calls. For 50 stacks across 4 hosts,
this meant ~200 parallel SSH connections, causing "Connection lost" errors.

Now queries each host once for all running compose projects using:
  docker ps --format '{{.Label "com.docker.compose.project"}}' | sort -u

This reduces SSH calls from ~200 to just 4 (one per host).

Changes:
- Add get_running_stacks_on_host() in executor.py
- Add discover_all_stacks_on_all_hosts() in operations.py
- Update _discover_stacks_full() to use the batch approach

* Remove unused function and add tests

- Remove discover_stack_on_all_hosts() which is no longer used
- Add tests for get_running_stacks_on_host()
- Add tests for discover_all_stacks_on_all_hosts()
  - Verifies it returns correct StackDiscoveryResult
  - Verifies stray detection works
  - Verifies it makes only 1 call per host (not per stack)
2025-12-22 12:09:59 -08:00
Bas Nijholt
6fdb43e1e9 Add self-healing: detect and stop stray containers (#128)
* Add self-healing: detect and stop rogue containers

Adds the ability to detect and stop "rogue" containers - stacks running
on hosts they shouldn't be according to config.

Changes:
- `cf refresh`: Now scans ALL hosts and warns about rogues/duplicates
- `cf apply`: Stops rogue containers before migrations (new phase)
- New `--no-rogues` flag to skip rogue detection

Implementation:
- Add StackDiscoveryResult for full host scanning results
- Add discover_stack_on_all_hosts() to check all hosts in parallel
- Add stop_rogue_stacks() to stop containers on unauthorized hosts
- Update tests to include new no_rogues parameter

* Update README.md

* fix: Update refresh tests for _discover_stacks_full return type

The function now returns a tuple (discovered, rogues, duplicates)
for rogue/duplicate detection. Update test mocks accordingly.

* Rename "rogue" terminology to "stray" for consistency

Terminology update across the codebase:
- rogue_hosts -> stray_hosts
- is_rogue -> is_stray
- stop_rogue_stacks -> stop_stray_stacks
- _discover_rogues -> _discover_strays
- --no-rogues -> --no-strays
- _report_rogue_stacks -> _report_stray_stacks

"Stray" better complements "orphaned" (both evoke lost things)
while clearly indicating the stack is running somewhere it
shouldn't be.

* Update README.md

* Move asyncio import to top level

* Fix remaining rogue -> stray in docstrings and README

* Refactor: Extract shared helpers to reduce duplication

1. Extract _stop_stacks_on_hosts helper in operations.py
   - Shared by stop_orphaned_stacks and stop_stray_stacks
   - Reduces ~50 lines of duplicated code

2. Refactor _discover_strays to reuse _discover_stacks_full
   - Removes duplicate discovery logic from lifecycle.py
   - Calls management._discover_stacks_full and merges duplicates

* Add PR review prompt

* Fix typos in PR review prompt

* Move import to top level (no in-function imports)

* Update README.md

* Remove obvious comments
2025-12-22 10:22:09 -08:00
Bas Nijholt
620e797671 fix: Add entrypoint to create passwd entry for non-root users (#127) 2025-12-22 07:31:59 -08:00
Bas Nijholt
031a2af6f3 fix: Correct SSH key volume mount path in docker-compose.yml (#126) 2025-12-22 06:55:59 -08:00
Bas Nijholt
f69eed7721 docs(readme): position as Dockge for multi-host (#123)
* docs(readme): position as Dockge for multi-host

- Reference Dockge (which we've used) instead of Portainer
- Move Portainer mention to "Your files" bullet as contrast
- Link to Dockge repo

* docs(readme): add agentless bullet, link Dockge

- Add "Agentless" bullet highlighting SSH-only approach
- Link to Dockge as contrast (they require agents for multi-host)
- Update NOTE to focus on agentless, CLI-first positioning
2025-12-21 23:28:26 -08:00
Bas Nijholt
5a1fd4e29f docs(readme): add value propositions and fix image URL (#122)
- Add bullet points highlighting key benefits after NOTE block
- Update NOTE to position as file-based Portainer alternative
- Fix hero image URL from http to https
- Add alt text to hero image for accessibility
2025-12-21 23:17:18 -08:00
Bas Nijholt
26dea691ca feat(docker): make container user configurable via CF_UID/CF_GID (#118)
* feat(docker): make container user configurable via CF_UID/CF_GID

Add support for running compose-farm containers as a non-root user
to preserve file ownership on mounted volumes. This prevents files
like compose-farm-state.yaml and web UI config edits from being
owned by root on NFS mounts.

Set CF_UID, CF_GID, and CF_HOME environment variables to run as
your user. Defaults to root (0:0) for backwards compatibility.

* docs: document non-root user configuration for Docker

- Add CF_UID/CF_GID/CF_HOME documentation to README and getting-started
- Add XDG config volume mount for backup/log persistence across restarts
- Update SSH volume examples to use CF_HOME variable

* fix(docker): allow non-root user access and add USER env for SSH

- Add `chmod 755 /root` to Dockerfile so non-root users can access
  the installed tool at /root/.local/share/uv/tools/compose-farm
- Add USER environment variable to docker-compose.yml for SSH to work
  when running as non-root (UID not in /etc/passwd)
- Update docs to include CF_USER in the setup instructions
- Support building from local source with SETUPTOOLS_SCM_PRETEND_VERSION

* fix(docker): revert local build changes, keep only chmod 755 /root

Remove the local source build logic that was added during testing.
The only required change is `chmod 755 /root` to allow non-root users
to access the installed tool.

* docs: add .envrc.example for direnv users

* docs: mention direnv option in README and getting-started
2025-12-21 22:19:40 -08:00
Bas Nijholt
56d64bfe7a fix(web): exclude orphaned stacks from running count (#119)
The dashboard showed "stopped: -1" when orphaned stacks existed because
running_count included stacks in state but removed from config. Now only
stacks that are both in config AND deployed are counted as running.
2025-12-21 21:59:05 -08:00
Bas Nijholt
5ddbdcdf9e docs(demos): update recordings and fix demo scripts (#115) 2025-12-21 19:17:16 -08:00
Bas Nijholt
dd16becad1 feat(web): add Repo command to command palette (#117)
Adds a new "Repo" command that opens the GitHub repository in a new tab,
similar to the existing "Docs" command.
2025-12-21 15:25:04 -08:00
Bas Nijholt
df683a223f fix(web): wait for terminal expand transition before scrolling (#116)
- Extracts generic `expandCollapse(toggle, scrollTarget)` function for reuse with any DaisyUI collapse
- Fixes scrolling when clicking action buttons (pull, logs, etc.) while terminal is collapsed - now waits for CSS transition before scrolling
- Fixes shell via command palette - expands Container Shell and scrolls to actual terminal (not collapse header)
- Fixes scroll position not resetting when navigating via command palette
2025-12-21 15:17:59 -08:00
Bas Nijholt
fdb00e7655 refactor(web): store backups in XDG config directory (#113)
* refactor(web): store backups in XDG config directory

Move file backups from `.backups/` alongside the file to
`~/.config/compose-farm/backups/` (respecting XDG_CONFIG_HOME).
The original file path is mirrored inside to avoid name collisions.

* docs(web): document automatic backup location

* refactor(paths): extract shared config_dir() function

* fix(web): use path anchor for Windows compatibility
2025-12-21 15:08:15 -08:00
Bas Nijholt
90657a025f docs: fix missing CLI options and improve docs-review prompt (#114)
* docs: fix missing CLI options and improve docs-review prompt

- Add missing --config option docs for cf ssh setup and cf ssh status
- Enhance .prompts/docs-review.md with:
  - Quick reference table mapping docs to source files
  - Runnable bash commands for quick checks
  - Specific code paths instead of vague references
  - Web UI documentation section
  - Common gotchas section
  - Ready-to-apply fix template format
  - Post-fix verification steps

* docs: add self-review step to docs-review prompt

* docs: make docs-review prompt discovery-based and less brittle

- Use discovery commands (git ls-files, grep, find) instead of hardcoded lists
- Add 'What This Prompt Is For' section clarifying manual vs automated checks
- Simplify checklist to 10 sections focused on judgment-based review
- Remove hardcoded file paths in favor of search patterns
- Make commands dynamically discover CLI structure

* docs: simplify docs-review prompt, avoid duplicating automated checks

- Remove checks already handled by CI (README help output, command table)
- Focus on judgment-based review: accuracy, completeness, clarity
- Reduce from 270 lines to 117 lines
- Highlight that docs/commands.md options tables are manually maintained
2025-12-21 15:07:37 -08:00
93 changed files with 4660 additions and 655 deletions

6
.envrc.example Normal file
View File

@@ -0,0 +1,6 @@
# Run containers as current user (preserves file ownership on NFS mounts)
# Copy this file to .envrc and run: direnv allow
export CF_UID=$(id -u)
export CF_GID=$(id -g)
export CF_HOME=$HOME
export CF_USER=$USER

2
.gitignore vendored
View File

@@ -37,6 +37,7 @@ ENV/
.coverage
.pytest_cache/
htmlcov/
.code/
# Local config (don't commit real configs)
compose-farm.yaml
@@ -45,3 +46,4 @@ coverage.xml
.env
homepage/
site/
.playwright-mcp/

View File

@@ -21,7 +21,7 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.9
hooks:
- id: ruff
- id: ruff-check
args: [--fix]
- id: ruff-format

View File

@@ -1,118 +1,117 @@
Review all documentation in this repository for accuracy, completeness, and consistency. Cross-reference documentation against the actual codebase to identify issues.
Review documentation for accuracy, completeness, and consistency. Focus on things that require judgment—automated checks handle the rest.
## Scope
## What's Already Automated
Review all documentation files:
- docs/*.md (primary documentation)
- README.md (repository landing page)
- CLAUDE.md (development guidelines)
- examples/README.md (example configurations)
Don't waste time on these—CI and pre-commit hooks handle them:
## Review Checklist
- **README help output**: `markdown-code-runner` regenerates `cf --help` blocks in CI
- **README command table**: Pre-commit hook verifies commands are listed
- **Linting/formatting**: Handled by pre-commit
### 1. Command Documentation
## What This Review Is For
For each documented command, verify against the CLI source code:
Focus on things that require judgment:
- Command exists in codebase
- All options are documented with correct names, types, and defaults
- Short options (-x) match long options (--xxx)
- Examples would work as written
- Check for undocumented commands or options
1. **Accuracy**: Does the documentation match what the code actually does?
2. **Completeness**: Are there undocumented features, options, or behaviors?
3. **Clarity**: Would a new user understand this? Are examples realistic?
4. **Consistency**: Do different docs contradict each other?
5. **Freshness**: Has the code changed in ways the docs don't reflect?
Run `--help` for each command to verify.
## Review Process
### 2. Configuration Documentation
### 1. Check Recent Changes
Verify against Pydantic models in the config module:
```bash
# What changed recently that might need doc updates?
git log --oneline -20 | grep -iE "feat|fix|add|remove|change|option"
- All config keys are documented
- Types match Pydantic field types
- Required vs optional fields are correct
- Default values are accurate
- Config file search order matches code
- Example YAML is valid and uses current schema
# What code files changed?
git diff --name-only HEAD~20 | grep "\.py$"
```
### 3. Architecture Documentation
Look for new features, changed defaults, renamed options, or removed functionality.
Verify against actual directory structure:
### 2. Verify docs/commands.md Options Tables
- File paths match actual source code location
- All modules listed actually exist
- No modules are missing from the list
- Component descriptions match code functionality
- CLI module list includes all command files
The README auto-updates help output, but `docs/commands.md` has **manually maintained options tables**. These can drift.
### 4. State and Data Files
For each command's options table, compare against `cf <command> --help`:
- Are all options listed?
- Are short flags correct?
- Are defaults accurate?
- Are descriptions accurate?
Verify against state and path modules:
**Pay special attention to subcommands** (`cf config *`, `cf ssh *`)—these have their own options that are easy to miss.
- State file name and location are correct
- State file format matches actual structure
- Log file name and location are correct
- What triggers state/log updates is accurate
### 3. Verify docs/configuration.md
### 5. Installation Documentation
Compare against Pydantic models in the source:
Verify against pyproject.toml:
```bash
# Find the config models
grep -r "class.*BaseModel" src/ --include="*.py" -A 15
```
- Python version requirement matches requires-python
- Package name is correct
- Optional dependencies are documented
- CLI entry points are mentioned
- Installation methods work as documented
Check:
- All config keys documented
- Types and defaults match code
- Config file search order is accurate
- Example YAML would actually work
### 6. Feature Claims
### 4. Verify docs/architecture.md
For each claimed feature, verify it exists and works as described.
```bash
# What source files actually exist?
git ls-files "src/**/*.py"
```
### 7. Cross-Reference Consistency
Check:
- Listed files exist
- No files are missing from the list
- Descriptions match what the code does
Check for conflicts between documentation files:
### 5. Check Examples
- README vs docs/index.md (should be consistent)
- CLAUDE.md vs actual code structure
- Command tables match across files
- Config examples are consistent
For examples in any doc:
- Would the YAML/commands actually work?
- Are service names, paths, and options realistic?
- Do examples use current syntax (not deprecated options)?
### 8. Recent Changes Check
### 6. Cross-Reference Consistency
Before starting the review:
The same info appears in multiple places. Check for conflicts:
- README.md vs docs/index.md
- docs/commands.md vs CLAUDE.md command tables
- Config examples across different docs
- 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
### 7. Self-Check This Prompt
### 9. Auto-Generated Content
This prompt can become outdated too. If you notice:
- New automated checks that should be listed above
- New doc files that need review guidelines
- Patterns that caused issues
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
Include prompt updates in your fixes.
## Output Format
Provide findings in these categories:
Categorize findings:
1. **Critical Issues**: Incorrect information that would cause user problems
2. **Inaccuracies**: Technical errors, wrong defaults, incorrect paths
3. **Missing Documentation**: Features/commands that exist but aren't documented
4. **Outdated Content**: Information that was once true but no longer is
5. **Inconsistencies**: Conflicts between different documentation files
6. **Minor Issues**: Typos, formatting, unclear wording
7. **Verified Accurate**: Sections confirmed to be correct
1. **Critical**: Wrong info that would break user workflows
2. **Inaccuracy**: Technical errors (wrong defaults, paths, types)
3. **Missing**: Undocumented features or options
4. **Outdated**: Was true, no longer is
5. **Inconsistency**: Docs contradict each other
6. **Minor**: Typos, unclear wording
For each issue, include:
- File path and line number (if applicable)
- What the documentation says
- What the code actually does
- Suggested fix
For each issue, provide a ready-to-apply fix:
```
### Issue: [Brief description]
- **File**: docs/commands.md:652
- **Problem**: `cf ssh setup` has `--config` option but it's not documented
- **Fix**: Add `--config, -c PATH` to the options table
- **Verify**: `cf ssh setup --help`
```

15
.prompts/pr-review.md Normal file
View File

@@ -0,0 +1,15 @@
Review the pull request for:
- **Code cleanliness**: Is the implementation clean and well-structured?
- **DRY principle**: Does it avoid duplication?
- **Code reuse**: Are there parts that should be reused from other places?
- **Organization**: Is everything in the right place?
- **Consistency**: Is it in the same style as other parts of the codebase?
- **Simplicity**: Is it not over-engineered? Remember KISS and YAGNI. No dead code paths and NO defensive programming.
- **User experience**: Does it provide a good user experience?
- **PR**: Is the PR description and title clear and informative?
- **Tests**: Are there tests, and do they cover the changes adequately? Are they testing something meaningful or are they just trivial?
- **Live tests**: Test the changes in a REAL live environment to ensure they work as expected, use the config in `/opt/stacks/compose-farm.yaml`.
- **Rules**: Does the code follow the project's coding standards and guidelines as laid out in @CLAUDE.md?
Look at `git diff origin/main..HEAD` for the changes made in this pull request.

51
.prompts/update-demos.md Normal file
View File

@@ -0,0 +1,51 @@
Update demo recordings to match the current compose-farm.yaml configuration.
## Key Gotchas
1. **Never `git checkout` without asking** - check for uncommitted changes first
2. **Prefer `nas` stacks** - demos run locally on nas, SSH adds latency
3. **Terminal captures keyboard** - use `blur()` to release focus before command palette
4. **Clicking sidebar navigates away** - clicking h1 scrolls to top
5. **Buttons have icons, not text** - use `[data-tip="..."]` selectors
6. **`record.py` auto-restores config** - no manual cleanup needed after CLI demos
## Stacks Used in Demos
| Stack | CLI Demos | Web Demos | Notes |
|-------|-----------|-----------|-------|
| `audiobookshelf` | quickstart, migration, apply | - | Migrates nas→anton |
| `grocy` | update | navigation, stack, workflow, console | - |
| `immich` | logs, compose | shell | Multiple containers |
| `dozzle` | - | workflow | - |
## CLI Demos
**Files:** `docs/demos/cli/*.tape`
Check:
- `quickstart.tape`: `bat -r` line ranges match current config structure
- `migration.tape`: nvim keystrokes work, stack exists on nas
- `compose.tape`: exec commands produce meaningful output
Run: `python docs/demos/cli/record.py [demo]`
## Web Demos
**Files:** `docs/demos/web/demo_*.py`
Check:
- Stack names in demos still exist in config
- Selectors match current templates (grep for IDs in `templates/`)
- Shell demo uses command palette for ALL navigation
Run: `python docs/demos/web/record.py [demo]`
## Before Recording
```bash
# Check for uncommitted config changes
git -C /opt/stacks diff compose-farm.yaml
# Verify stacks are running
cf ps audiobookshelf grocy immich dozzle
```

View File

@@ -16,5 +16,13 @@ RUN apk add --no-cache openssh-client
COPY --from=builder /root/.local/share/uv/tools/compose-farm /root/.local/share/uv/tools/compose-farm
COPY --from=builder /usr/local/bin/cf /usr/local/bin/compose-farm /usr/local/bin/
ENTRYPOINT ["cf"]
# Allow non-root users to access the installed tool
# (required when running with user: "${CF_UID:-0}:${CF_GID:-0}")
RUN chmod 755 /root
# Allow non-root users to add passwd entries (required for SSH)
RUN chmod 666 /etc/passwd
# Entrypoint creates /etc/passwd entry for non-root UIDs (required for SSH)
ENTRYPOINT ["sh", "-c", "[ $(id -u) != 0 ] && echo ${USER:-u}:x:$(id -u):$(id -g)::${HOME:-/}:/bin/sh >> /etc/passwd; exec cf \"$@\"", "--"]
CMD ["--help"]

133
README.md
View File

@@ -5,12 +5,19 @@
[![License](https://img.shields.io/github/license/basnijholt/compose-farm)](LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/basnijholt/compose-farm)](https://github.com/basnijholt/compose-farm/stargazers)
<img src="http://files.nijho.lt/compose-farm.png" align="right" style="width: 300px;" />
<img src="https://files.nijho.lt/compose-farm.png" alt="Compose Farm logo" align="right" style="width: 300px;" />
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
> [!NOTE]
> Run `docker compose` commands across multiple hosts via SSH. One YAML maps stacks to hosts. Run `cf apply` and reality matches your config—stacks start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic.
> Agentless multi-host Docker Compose. CLI-first with a web UI. Your files stay as plain folders—version-controllable, no lock-in. Run `cf apply` and reality matches your config.
**Why Compose Farm?**
- **Your files, your control** — Plain folders + YAML, not locked in Portainer. Version control everything.
- **Agentless** — Just SSH, no agents to deploy (unlike [Dockge](https://github.com/louislam/dockge)).
- **Zero changes required** — Existing compose files work as-is.
- **Grows with you** — Start single-host, scale to multi-host seamlessly.
- **Declarative** — Change config, run `cf apply`, reality matches.
## Quick Demo
@@ -36,8 +43,8 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [What Compose Farm doesn't do](#what-compose-farm-doesnt-do)
- [Installation](#installation)
- [SSH Authentication](#ssh-authentication)
- [SSH Agent (default)](#ssh-agent-default)
- [Dedicated SSH Key (recommended for Docker/Web UI)](#dedicated-ssh-key-recommended-for-dockerweb-ui)
- [SSH Agent](#ssh-agent)
- [Dedicated SSH Key (default for Docker)](#dedicated-ssh-key-default-for-docker)
- [Configuration](#configuration)
- [Single-host example](#single-host-example)
- [Multi-host example](#multi-host-example)
@@ -47,6 +54,7 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [CLI `--help` Output](#cli---help-output)
- [Auto-Migration](#auto-migration)
- [Traefik Multihost Ingress (File Provider)](#traefik-multihost-ingress-file-provider)
- [Host Resource Monitoring (Glances)](#host-resource-monitoring-glances)
- [Comparison with Alternatives](#comparison-with-alternatives)
- [License](#license)
@@ -177,15 +185,33 @@ docker run --rm \
ghcr.io/basnijholt/compose-farm up --all
```
**Running as non-root user** (recommended for NFS mounts):
By default, containers run as root. To preserve file ownership on mounted volumes
(e.g., `compose-farm-state.yaml`, config edits), set these environment variables:
```bash
# Add to .env file (one-time setup)
echo "CF_UID=$(id -u)" >> .env
echo "CF_GID=$(id -g)" >> .env
echo "CF_HOME=$HOME" >> .env
echo "CF_USER=$USER" >> .env
```
Or use [direnv](https://direnv.net/) (copies `.envrc.example` to `.envrc`):
```bash
cp .envrc.example .envrc && direnv allow
```
</details>
## SSH Authentication
Compose Farm uses SSH to run commands on remote hosts. There are two authentication methods:
### SSH Agent (default)
### SSH Agent
Works out of the box if you have an SSH agent running with your keys loaded:
Works out of the box when running locally if you have an SSH agent running with your keys loaded:
```bash
# Verify your agent has keys
@@ -195,9 +221,9 @@ ssh-add -l
cf up --all
```
### Dedicated SSH Key (recommended for Docker/Web UI)
### Dedicated SSH Key (default for Docker)
When running compose-farm in Docker, the SSH agent connection can be lost (e.g., after container restart). The `cf ssh` command sets up a dedicated key that persists:
When running in Docker, SSH agent sockets are ephemeral and can be lost after container restarts. The `cf ssh` command sets up a dedicated key that persists:
```bash
# Generate key and copy to all configured hosts
@@ -216,15 +242,22 @@ When running in Docker, mount a volume to persist the SSH keys. Choose ONE optio
**Option 1: Host path (default)** - keys at `~/.ssh/compose-farm/id_ed25519`
```yaml
volumes:
- ~/.ssh/compose-farm:/root/.ssh
- ~/.ssh/compose-farm:${CF_HOME:-/root}/.ssh
```
**Option 2: Named volume** - managed by Docker
```yaml
volumes:
- cf-ssh:/root/.ssh
- cf-ssh:${CF_HOME:-/root}/.ssh
```
**Option 3: SSH agent forwarding** - if you prefer using your host's ssh-agent
```yaml
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
```
Note: Requires `SSH_AUTH_SOCK` environment variable to be set. The socket path is ephemeral and changes across sessions.
Run setup once after starting the container (while the SSH agent still works):
```bash
@@ -233,6 +266,8 @@ docker compose exec web cf ssh setup
The keys will persist across restarts.
**Note:** When running as non-root (with `CF_UID`/`CF_GID`), set `CF_HOME` to your home directory so SSH finds the keys at the correct path.
</details>
## Configuration
@@ -422,6 +457,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ copy it or customize the installation. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose │
│ Traefik labels. │
│ refresh Update local state from running stacks. │
│ check Validate configuration, traefik labels, mounts, and networks. │
│ init-network Create Docker network on hosts with consistent settings. │
│ config Manage compose-farm configuration files. │
│ ssh Manage SSH keys for passwordless authentication. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Lifecycle ──────────────────────────────────────────────────────────────────╮
│ up Start stacks (docker compose up -d). Auto-migrates if host │
│ changed. │
@@ -433,18 +477,10 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ 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).
│ apply Make reality match config (start, migrate, stop
│ strays/orphans as needed). │
│ compose Run any docker compose command on a stack. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose │
│ Traefik labels. │
│ refresh Update local state from running stacks. │
│ check Validate configuration, traefik labels, mounts, and networks. │
│ init-network Create Docker network on hosts with consistent settings. │
│ config Manage compose-farm configuration files. │
│ ssh Manage SSH keys for passwordless authentication. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show stack logs. With --service, shows logs for just that │
│ service. │
@@ -694,22 +730,25 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf apply [OPTIONS]
Make reality match config (start, migrate, stop as needed).
Make reality match config (start, migrate, stop strays/orphans as needed).
This is the "reconcile" command that ensures running stacks match your
config file. It will:
1. Stop orphaned stacks (in state but removed from config)
2. Migrate stacks on wrong host (host in state ≠ host in config)
3. Start missing stacks (in config but not in state)
2. Stop stray stacks (running on unauthorized hosts)
3. Migrate stacks on wrong host (host in state ≠ host in config)
4. Start missing stacks (in config but not in state)
Use --dry-run to preview changes before applying.
Use --no-orphans to only migrate/start without stopping orphaned stacks.
Use --no-orphans to skip stopping orphaned stacks.
Use --no-strays to skip stopping stray stacks.
Use --full to also run 'up' on all stacks (picks up compose/env changes).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --dry-run -n Show what would change without executing │
│ --no-orphans Only migrate, don't stop orphaned stacks │
│ --no-strays Don't stop stray stacks (running on wrong host) │
│ --full -f Also run up on all stacks to apply config │
│ changes │
│ --config -c PATH Path to config file │
@@ -1287,6 +1326,52 @@ Update your Traefik config to use directory watching instead of a single file:
- --providers.file.watch=true
```
## Host Resource Monitoring (Glances)
The web UI can display real-time CPU, memory, and load stats for all configured hosts. This uses [Glances](https://nicolargo.github.io/glances/), a cross-platform system monitoring tool with a REST API.
**Setup**
1. Deploy a Glances stack that runs on all hosts:
```yaml
# glances/compose.yaml
name: glances
services:
glances:
image: nicolargo/glances:latest
container_name: glances
restart: unless-stopped
pid: host
ports:
- "61208:61208"
environment:
- GLANCES_OPT=-w # Enable web server mode
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
```
2. Add it to your config as a multi-host stack:
```yaml
# compose-farm.yaml
stacks:
glances: all # Runs on every host
glances_stack: glances # Enables resource stats in web UI
```
3. Deploy: `cf up glances`
The web UI dashboard will now show a "Host Resources" section with live stats from all hosts. Hosts where Glances is unreachable show an error indicator.
**Live Stats Page**
With Glances configured, a Live Stats page (`/live-stats`) shows all running containers across all hosts:
- **Columns**: Stack, Service, Host, Image, Status, Uptime, CPU, Memory, Net I/O
- **Features**: Sorting, filtering, live updates (no SSH required—uses Glances REST API)
## Comparison with Alternatives
There are many ways to run containers on multiple hosts. Here is where Compose Farm sits:

View File

@@ -1,38 +1,56 @@
services:
cf:
image: ghcr.io/basnijholt/compose-farm:latest
# Run as current user to preserve file ownership on mounted volumes
# Set CF_UID=$(id -u) CF_GID=$(id -g) in your environment or .env file
# Defaults to root (0:0) for backwards compatibility
user: "${CF_UID:-0}:${CF_GID:-0}"
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
# Compose directory (contains compose files AND compose-farm.yaml config)
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
# SSH keys for passwordless auth (generated by `cf ssh setup`)
# Choose ONE option below (use the same option for both cf and web services):
# Option 1: Host path (default) - keys at ~/.ssh/compose-farm/id_ed25519
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
# Option 2: Named volume - managed by Docker, shared between services
# - cf-ssh:/root/.ssh
# - cf-ssh:${CF_HOME:-/root}/.ssh
# Option 3: SSH agent forwarding (uncomment if using ssh-agent)
# - ${SSH_AUTH_SOCK}:/ssh-agent:ro
environment:
- SSH_AUTH_SOCK=/ssh-agent
# Config file path (state stored alongside it)
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
# HOME must match the user running the container for SSH to find keys
- HOME=${CF_HOME:-/root}
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
- USER=${CF_USER:-root}
web:
image: ghcr.io/basnijholt/compose-farm:latest
restart: unless-stopped
command: web --host 0.0.0.0 --port 9000
# Run as current user to preserve file ownership on mounted volumes
user: "${CF_UID:-0}:${CF_GID:-0}"
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
# SSH keys - use the SAME option as cf service above
# Option 1: Host path (default)
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
# Option 2: Named volume
# - cf-ssh:/root/.ssh
# - cf-ssh:${CF_HOME:-/root}/.ssh
# Option 3: SSH agent forwarding (uncomment if using ssh-agent)
# - ${SSH_AUTH_SOCK}:/ssh-agent:ro
# XDG config dir for backups and image digest logs (persists across restarts)
- ${CF_XDG_CONFIG:-~/.config/compose-farm}:${CF_HOME:-/root}/.config/compose-farm
environment:
- SSH_AUTH_SOCK=/ssh-agent
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
# Used to detect self-updates and run via SSH to survive container restart
- CF_WEB_STACK=compose-farm
# HOME must match the user running the container for SSH to find keys
- HOME=${CF_HOME:-/root}
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
- USER=${CF_USER:-root}
labels:
- traefik.enable=true
- traefik.http.routers.compose-farm.rule=Host(`compose-farm.${DOMAIN}`)

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb1372a59a4ed1ac74d3864d7a84dd5311fce4cb6c6a00bf3a574bc2f98d5595
size 895927
oid sha256:01dabdd8f62773823ba2b8dc74f9931f1a1b88215117e6a080004096025491b0
size 901456

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f339a85f3d930db5a020c9f77e106edc5f44ea7dee6f68557106721493c24ef8
size 205907
oid sha256:134c903a6b3acfb933617b33755b0cdb9bac2a59e5e35b64236e248a141d396d
size 206883

3
docs/assets/compose.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8b3cdb3486ec79b3ddb2f7571c13d54ac9aed182edfe708eff76a966a90cfc7
size 1132310

3
docs/assets/compose.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3c4d4a62f062f717df4e6752efced3caea29004dc90fe97fd7633e7f0ded9db
size 341057

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:388aa49a1269145698f9763452aaf6b9c6232ea9229abe1dae304df558e29695
size 403442
oid sha256:6c1bb48cc2f364681515a4d8bd0c586d133f5a32789b7bb64524ad7d9ed0a8e9
size 543135

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9b8bf4dcb8ee67270d4a88124b4dd4abe0dab518e73812ee73f7c66d77f146e2
size 228025
oid sha256:5f82d96137f039f21964c15c1550aa1b1f0bb2d52c04d012d253dbfbd6fad096
size 268086

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:16b9a28137dfae25488e2094de85766a039457f5dca20c2d84ac72e3967c10b9
size 164237
oid sha256:2a4045b00d90928f42c7764b3c24751576cfb68a34c6e84d12b4e282d2e67378
size 146467

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e0fbe697a1f8256ce3b9a6a64c7019d42769134df9b5b964e5abe98a29e918fd
size 68242
oid sha256:f1b94416ed3740853f863e19bf45f26241a203fb0d7d187160a537f79aa544fa
size 60353

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:629b8c80b98eb996b75439745676fd99a83f391ca25f778a71bd59173f814c2f
size 1194931
oid sha256:848d9c48fb7511da7996149277c038589fad1ee406ff2f30c28f777fc441d919
size 1183641

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33fd46f2d8538cc43be4cb553b3af9d8b412f282ee354b6373e2793fe41c799b
size 405057
oid sha256:e747ee71bb38b19946005d5a4def4d423dadeaaade452dec875c4cb2d24a5b77
size 407373

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ccd96e33faba5f297999917d89834b29d58bd2a8929eea8d62875e3d8830bd5c
size 3198466
oid sha256:d32c9a3eec06e57df085ad347e6bf61e323f8bd8322d0c540f0b9d4834196dfd
size 3589776

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:979a1a21303bbf284b3510981066ef05c41c1035b34392fecc7bee472116e6db
size 967564
oid sha256:6c54eda599389dac74c24c83527f95cd1399e653d7faf2972c2693d90e590597
size 1085344

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2067f4967a93b7ee3a8db7750c435f41b1fccd2919f3443da4b848c20cc54f23
size 124559
oid sha256:62f9b5ec71496197a3f1c3e3bca8967d603838804279ea7dbf00a70d3391ff6c
size 127123

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5471bd94e6d1b9d415547fa44de6021fdad2e1cc5b8b295680e217104aa749d6
size 98149
oid sha256:ac2b93d3630af87b44a135723c5d10e8287529bed17c28301b2802cd9593e9e8
size 98748

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dac5660cfe6574857ec055fac7822f25b7c5fcb10a836b19c86142515e2fbf75
size 1816075
oid sha256:7b50a7e9836c496c0989363d1440fa0a6ccdaa38ee16aae92b389b3cf3c3732f
size 2385110

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d4efec8ef5a99f2cb31d55cd71cdbf0bb8dd0cd6281571886b7c1f8b41c3f9da
size 1660764
oid sha256:ccbb3d5366c7734377e12f98cca0b361028f5722124f1bb7efa231f6aeffc116
size 2208044

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4135888689a10c5ae2904825d98f2a6d215c174a4bd823e25761f619590f04ff
size 3990104

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:87739cd6f6576a81100392d8d1e59d3e776fecc8f0721a31332df89e7fc8593d
size 5814274

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9348dd36e79192344476d61fbbffdb122a96ecc5829fbece1818590cfc521521
size 3373003
oid sha256:269993b52721ce70674d3aab2a4cd8c58aa621d4ba0739afedae661c90965b26
size 3678371

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bebbf8151434ba37bf5e46566a4e8b57812944281926f579d056bdc835ca26aa
size 2729799
oid sha256:0098b55bb6a52fa39f807a01fa352ce112bcb446e2a2acb963fb02d21b28c934
size 3088813

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3712afff6fcde00eb951264bb24d4301deb085d082b4e95ed4c1893a571938ee
size 1528294
oid sha256:4bf9d8c247d278799d1daea784fc662a22f12b1bd7883f808ef30f35025ebca6
size 4166443

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b218d400836a50661c9cdcce2d2b1e285cc5fe592cb42f58aae41f3e7d60684
size 1327413
oid sha256:02d5124217a94849bf2971d6d13d28da18c557195a81b9cca121fb7c07f0501b
size 3523244

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a232ddc1b9ddd9bf6b5d99c05153e1094be56f1952f02636ca498eb7484e096
size 3808675
oid sha256:412a0e68f8e52801cafbb9a703ca9577e7c14cc7c0e439160b9185961997f23c
size 4435697

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5a7c9f5f6d47074a6af135190fda6d0a1936cd7a0b04b3aa04ea7d99167a9e05
size 3333014
oid sha256:0e600a1d3216b44497a889f91eac94d62ef7207b4ed0471465dcb72408caa28e
size 3764693

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66f4547ed2e83b302d795875588d9a085af76071a480f1096f2bb64344b80c42
size 5428670
oid sha256:3c07a283f4f70c4ab205b0f0acb5d6f55e3ced4c12caa7a8d5914ffe3548233a
size 5768166

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75c8cdeefbbdcab2a240821d3410539f2a2cbe0a015897f4135404c80c3ac32c
size 6578366
oid sha256:562228841de976d70ee80999b930eadf3866a13ff2867d900279993744c44671
size 6667918

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff2e3ca5a46397efcd5f3a595e7d3c179266cc4f3f5f528b428f5ef2a423028e
size 12649149
oid sha256:845746ac1cb101c3077d420c4f3fda3ca372492582dc123ac8a031a68ae9b6b1
size 12943150

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2d739c5f77ddd9d90b609e31df620b35988081b7341fe225eb717d71a87caa88
size 12284953
oid sha256:189259558b5760c02583885168d7b0b47cf476cba81c7c028ec770f9d6033129
size 12415357

View File

@@ -290,6 +290,10 @@ cf pull immich --service database
Run any docker compose command on a stack. This is a passthrough to docker compose for commands not wrapped by cf.
<video autoplay loop muted playsinline>
<source src="/assets/compose.webm" type="video/webm">
</video>
```bash
cf compose [OPTIONS] STACK COMMAND [ARGS]...
```
@@ -649,7 +653,20 @@ cf ssh COMMAND
| `status` | Show SSH key status and host connectivity |
| `keygen` | Generate key without distributing |
**Options for `cf ssh setup` and `cf ssh keygen`:**
**Options for `cf ssh setup`:**
| Option | Description |
|--------|-------------|
| `--config, -c PATH` | Path to config file |
| `--force, -f` | Regenerate key even if it exists |
**Options for `cf ssh status`:**
| Option | Description |
|--------|-------------|
| `--config, -c PATH` | Path to config file |
**Options for `cf ssh keygen`:**
| Option | Description |
|--------|-------------|

View File

@@ -10,10 +10,10 @@ VHS-based terminal demo recordings for Compose Farm CLI.
```bash
# Record all demos
./docs/demos/cli/record.sh
python docs/demos/cli/record.py
# Record single demo
cd /opt/stacks && vhs docs/demos/cli/quickstart.tape
# Record specific demos
python docs/demos/cli/record.py quickstart migration
```
## Demos
@@ -23,6 +23,7 @@ cd /opt/stacks && vhs docs/demos/cli/quickstart.tape
| `install.tape` | Installing with `uv tool install` |
| `quickstart.tape` | `cf ps`, `cf up`, `cf logs` |
| `logs.tape` | Viewing logs |
| `compose.tape` | `cf compose` passthrough (--help, images, exec) |
| `update.tape` | `cf update` |
| `migration.tape` | Service migration |
| `apply.tape` | `cf apply` |

View File

@@ -0,0 +1,50 @@
# Compose Demo
# Shows that cf compose passes through ANY docker compose command
Output docs/assets/compose.gif
Output docs/assets/compose.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 550
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# cf compose runs ANY docker compose command on the right host"
Enter
Sleep 500ms
Type "# See ALL available compose commands"
Enter
Sleep 500ms
Type "cf compose immich --help"
Enter
Sleep 4s
Type "# Show images"
Enter
Sleep 500ms
Type "cf compose immich images"
Enter
Wait+Screen /immich/
Sleep 2s
Type "# Open shell in a container"
Enter
Sleep 500ms
Type "cf compose immich exec immich-machine-learning sh"
Enter
Wait+Screen /#/
Sleep 1s
Type "python3 --version"
Enter
Sleep 1s
Type "exit"
Enter
Sleep 500ms

View File

@@ -21,7 +21,7 @@ Type "# First, define your hosts..."
Enter
Sleep 500ms
Type "bat -r 1:11 compose-farm.yaml"
Type "bat -r 1:16 compose-farm.yaml"
Enter
Sleep 3s
Type "q"
@@ -31,7 +31,7 @@ Type "# Then map each stack to a host"
Enter
Sleep 500ms
Type "bat -r 13:30 compose-farm.yaml"
Type "bat -r 17:35 compose-farm.yaml"
Enter
Sleep 3s
Type "q"

134
docs/demos/cli/record.py Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Record CLI demos using VHS."""
import shutil
import subprocess
import sys
from pathlib import Path
from rich.console import Console
from compose_farm.config import load_config
from compose_farm.state import load_state
console = Console()
SCRIPT_DIR = Path(__file__).parent
STACKS_DIR = Path("/opt/stacks")
CONFIG_FILE = STACKS_DIR / "compose-farm.yaml"
OUTPUT_DIR = SCRIPT_DIR.parent.parent / "assets"
DEMOS = ["install", "quickstart", "logs", "compose", "update", "migration", "apply"]
def _run(cmd: list[str], **kw) -> bool:
return subprocess.run(cmd, check=False, **kw).returncode == 0
def _set_config(host: str) -> None:
"""Set audiobookshelf host in config file."""
_run(["sed", "-i", f"s/audiobookshelf: .*/audiobookshelf: {host}/", str(CONFIG_FILE)])
def _get_hosts() -> tuple[str | None, str | None]:
"""Return (config_host, state_host) for audiobookshelf."""
config = load_config()
state = load_state(config)
return config.stacks.get("audiobookshelf"), state.get("audiobookshelf")
def _setup_state(demo: str) -> bool:
"""Set up required state for demo. Returns False on failure."""
if demo not in ("migration", "apply"):
return True
config_host, state_host = _get_hosts()
if demo == "migration":
# Migration needs audiobookshelf on nas in BOTH config and state
if config_host != "nas":
console.print("[yellow]Setting up: config → nas[/yellow]")
_set_config("nas")
if state_host != "nas":
console.print("[yellow]Setting up: state → nas[/yellow]")
if not _run(["cf", "apply"], cwd=STACKS_DIR):
return False
elif demo == "apply":
# Apply needs config=nas, state=anton (so there's something to apply)
if config_host != "nas":
console.print("[yellow]Setting up: config → nas[/yellow]")
_set_config("nas")
if state_host == "nas":
console.print("[yellow]Setting up: state → anton[/yellow]")
_set_config("anton")
if not _run(["cf", "apply"], cwd=STACKS_DIR):
return False
_set_config("nas")
return True
def _record(name: str, index: int, total: int) -> bool:
"""Record a single demo."""
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] {name}")
if _run(["vhs", str(SCRIPT_DIR / f"{name}.tape")], cwd=STACKS_DIR):
console.print("[green] ✓ Done[/green]")
return True
console.print("[red] ✗ Failed[/red]")
return False
def _reset_after(demo: str, next_demo: str | None) -> None:
"""Reset state after demos that modify audiobookshelf."""
if demo not in ("quickstart", "migration"):
return
_set_config("nas")
if next_demo != "apply": # Let apply demo show the migration
_run(["cf", "apply"], cwd=STACKS_DIR)
def _restore_config(original: str) -> None:
"""Restore original config and sync state."""
console.print("[yellow]Restoring original config...[/yellow]")
CONFIG_FILE.write_text(original)
_run(["cf", "apply"], cwd=STACKS_DIR)
def _main() -> int:
if not shutil.which("vhs"):
console.print("[red]VHS not found. Install: brew install vhs[/red]")
return 1
if not _run(["git", "-C", str(STACKS_DIR), "diff", "--quiet", "compose-farm.yaml"]):
console.print("[red]compose-farm.yaml has uncommitted changes[/red]")
return 1
demos = [d for d in sys.argv[1:] if d in DEMOS] or DEMOS
if sys.argv[1:] and not demos:
console.print(f"[red]Unknown demo. Available: {', '.join(DEMOS)}[/red]")
return 1
# Save original config to restore after recording
original_config = CONFIG_FILE.read_text()
try:
for i, demo in enumerate(demos, 1):
if not _setup_state(demo):
return 1
if not _record(demo, i, len(demos)):
return 1
_reset_after(demo, demos[i] if i < len(demos) else None)
finally:
_restore_config(original_config)
# Move outputs
OUTPUT_DIR.mkdir(exist_ok=True)
for f in (STACKS_DIR / "docs/assets").glob("*.[gw]*"):
shutil.move(str(f), str(OUTPUT_DIR / f.name))
console.print(f"\n[green]Done![/green] Saved to {OUTPUT_DIR}")
return 0
if __name__ == "__main__":
sys.exit(_main())

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Record all VHS demos
# Run this on a Docker host with compose-farm configured
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEMOS_DIR="$(dirname "$SCRIPT_DIR")"
DOCS_DIR="$(dirname "$DEMOS_DIR")"
REPO_DIR="$(dirname "$DOCS_DIR")"
OUTPUT_DIR="$DOCS_DIR/assets"
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Check for VHS
if ! command -v vhs &> /dev/null; then
echo "VHS not found. Install with:"
echo " brew install vhs"
echo " # or"
echo " go install github.com/charmbracelet/vhs@latest"
exit 1
fi
# Ensure output directory exists
mkdir -p "$OUTPUT_DIR"
# Temp output dir (VHS runs from /opt/stacks, so relative paths go here)
TEMP_OUTPUT="/opt/stacks/docs/assets"
mkdir -p "$TEMP_OUTPUT"
# Change to /opt/stacks so cf commands use installed version (not editable install)
cd /opt/stacks
# Ensure compose-farm.yaml has no uncommitted changes (safety check)
if ! git diff --quiet compose-farm.yaml; then
echo -e "${RED}Error: compose-farm.yaml has uncommitted changes${NC}"
echo "Commit or stash your changes before recording demos"
exit 1
fi
echo -e "${BLUE}Recording VHS demos...${NC}"
echo "Output directory: $OUTPUT_DIR"
echo ""
# Function to record a tape
record_tape() {
local tape=$1
local name=$(basename "$tape" .tape)
echo -e "${GREEN}Recording:${NC} $name"
if vhs "$tape"; then
echo -e "${GREEN} ✓ Done${NC}"
else
echo -e "${RED} ✗ Failed${NC}"
return 1
fi
}
# Record demos in logical order
echo -e "${YELLOW}=== Phase 1: Basic demos ===${NC}"
record_tape "$SCRIPT_DIR/install.tape"
record_tape "$SCRIPT_DIR/quickstart.tape"
record_tape "$SCRIPT_DIR/logs.tape"
echo -e "${YELLOW}=== Phase 2: Update demo ===${NC}"
record_tape "$SCRIPT_DIR/update.tape"
echo -e "${YELLOW}=== Phase 3: Migration demo ===${NC}"
record_tape "$SCRIPT_DIR/migration.tape"
git -C /opt/stacks checkout compose-farm.yaml # Reset after migration
echo -e "${YELLOW}=== Phase 4: Apply demo ===${NC}"
record_tape "$SCRIPT_DIR/apply.tape"
# Move GIFs and WebMs from temp location to repo
echo ""
echo -e "${BLUE}Moving recordings to repo...${NC}"
mv "$TEMP_OUTPUT"/*.gif "$OUTPUT_DIR/" 2>/dev/null || true
mv "$TEMP_OUTPUT"/*.webm "$OUTPUT_DIR/" 2>/dev/null || true
rmdir "$TEMP_OUTPUT" 2>/dev/null || true
rmdir "$(dirname "$TEMP_OUTPUT")" 2>/dev/null || true
echo ""
echo -e "${GREEN}Done!${NC} Recordings saved to $OUTPUT_DIR/"
ls -la "$OUTPUT_DIR"/*.gif "$OUTPUT_DIR"/*.webm 2>/dev/null || echo "No recordings found (check for errors above)"

View File

@@ -21,24 +21,37 @@ import uvicorn
from compose_farm.config import Config as CFConfig
from compose_farm.config import load_config
from compose_farm.executor import (
get_container_compose_labels as _original_get_compose_labels,
)
from compose_farm.glances import ContainerStats
from compose_farm.glances import fetch_container_stats as _original_fetch_container_stats
from compose_farm.state import load_state as _original_load_state
from compose_farm.web.app import create_app
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
# NOTE: Do NOT import create_app here - it must be imported AFTER patches are applied
# to ensure the patched get_config is used by all route modules
if TYPE_CHECKING:
from collections.abc import Generator
from playwright.sync_api import BrowserContext, Page, Route
# Stacks to exclude from demo recordings (exact match)
DEMO_EXCLUDE_STACKS = {"arr"}
# Substrings to exclude from demo recordings (case-insensitive)
DEMO_EXCLUDE_PATTERNS = {"arr", "vpn", "tash"}
def _should_exclude(name: str) -> bool:
"""Check if a stack/container name should be excluded from demo."""
name_lower = name.lower()
return any(pattern in name_lower for pattern in DEMO_EXCLUDE_PATTERNS)
def _get_filtered_config() -> CFConfig:
"""Load config but filter out excluded stacks."""
config = load_config()
filtered_stacks = {
name: host for name, host in config.stacks.items() if name not in DEMO_EXCLUDE_STACKS
name: host for name, host in config.stacks.items() if not _should_exclude(name)
}
return CFConfig(
compose_dir=config.compose_dir,
@@ -46,6 +59,7 @@ def _get_filtered_config() -> CFConfig:
stacks=filtered_stacks,
traefik_file=config.traefik_file,
traefik_stack=config.traefik_stack,
glances_stack=config.glances_stack,
config_path=config.config_path,
)
@@ -53,7 +67,37 @@ def _get_filtered_config() -> CFConfig:
def _get_filtered_state(config: CFConfig) -> dict[str, str | list[str]]:
"""Load state but filter out excluded stacks."""
state = _original_load_state(config)
return {name: host for name, host in state.items() if name not in DEMO_EXCLUDE_STACKS}
return {name: host for name, host in state.items() if not _should_exclude(name)}
async def _filtered_fetch_container_stats(
host_name: str,
host_address: str,
port: int = 61208,
request_timeout: float = 10.0,
) -> tuple[list[ContainerStats] | None, str | None]:
"""Fetch container stats but filter out excluded containers."""
containers, error = await _original_fetch_container_stats(
host_name, host_address, port, request_timeout
)
if containers:
# Filter by container name (stack is empty at this point)
containers = [c for c in containers if not _should_exclude(c.name)]
return containers, error
async def _filtered_get_compose_labels(
config: CFConfig,
host_name: str,
) -> dict[str, tuple[str, str]]:
"""Get compose labels but filter out excluded stacks."""
labels = await _original_get_compose_labels(config, host_name)
# Filter out containers whose stack (project) name should be excluded
return {
name: (stack, service)
for name, (stack, service) in labels.items()
if not _should_exclude(stack)
}
@pytest.fixture(scope="session")
@@ -84,19 +128,23 @@ def server_url() -> Generator[str, None, None]:
# Patch at source module level so all callers get filtered versions
patches = [
# Patch load_state at source - all functions calling it get filtered state
# Patch load_config at source - get_config() calls this internally
patch("compose_farm.config.load_config", _get_filtered_config),
# Patch load_state at source and where imported
patch("compose_farm.state.load_state", _get_filtered_state),
# Patch get_config where imported
patch("compose_farm.web.routes.pages.get_config", _get_filtered_config),
patch("compose_farm.web.routes.api.get_config", _get_filtered_config),
patch("compose_farm.web.routes.actions.get_config", _get_filtered_config),
patch("compose_farm.web.app.get_config", _get_filtered_config),
patch("compose_farm.web.ws.get_config", _get_filtered_config),
patch("compose_farm.web.routes.pages.load_state", _get_filtered_state),
# Patch container fetch to filter out excluded containers (Live Stats page)
patch("compose_farm.glances.fetch_container_stats", _filtered_fetch_container_stats),
# Patch compose labels to filter out excluded stacks
patch("compose_farm.executor.get_container_compose_labels", _filtered_get_compose_labels),
]
for p in patches:
p.start()
# Import create_app AFTER patches are started so route modules see patched get_config
from compose_farm.web.app import create_app # noqa: PLC0415
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
@@ -160,6 +208,7 @@ def recording_context(
if url.startswith(url_prefix):
route.fulfill(status=200, content_type=content_type, body=filepath.read_bytes())
return
print(f"UNCACHED CDN request: {url}")
route.abort("failed")
context.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)
@@ -176,6 +225,35 @@ def recording_page(recording_context: BrowserContext) -> Generator[Page, None, N
page.close()
@pytest.fixture
def wide_recording_context(
browser: Any, # pytest-playwright's browser fixture
recording_output_dir: Path,
) -> Generator[BrowserContext, None, None]:
"""Browser context with wider viewport for demos needing more horizontal space.
NOTE: This fixture does NOT use CDN interception (unlike recording_context).
CDN interception was causing inline scripts from containers.html to be
removed from the DOM, likely due to Tailwind's browser plugin behavior.
"""
context = browser.new_context(
viewport={"width": 1920, "height": 1080},
record_video_dir=str(recording_output_dir),
record_video_size={"width": 1920, "height": 1080},
)
yield context
context.close()
@pytest.fixture
def wide_recording_page(wide_recording_context: BrowserContext) -> Generator[Page, None, None]:
"""Page with wider viewport for demos needing more horizontal space."""
page = wide_recording_context.new_page()
yield page
page.close()
# Demo helper functions

View File

@@ -60,10 +60,14 @@ def test_demo_console(recording_page: Page, server_url: str) -> None:
page.keyboard.press("Enter")
pause(page, 2500) # Wait for output
# Scroll down to show the Editor section with Compose Farm config
editor_section = page.locator(".collapse", has_text="Editor").first
editor_section.scroll_into_view_if_needed()
pause(page, 800)
# Smoothly scroll down to show the Editor section with Compose Farm config
page.evaluate("""
const editor = document.getElementById('console-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Wait for Monaco editor to load with config content
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)

View File

@@ -0,0 +1,85 @@
"""Demo: Live Stats page.
Records a ~20 second demo showing:
- Navigating to Live Stats via command palette
- Container table with real-time stats
- Filtering containers
- Sorting by different columns
- Auto-refresh countdown
Run: pytest docs/demos/web/demo_live_stats.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
open_command_palette,
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_live_stats(wide_recording_page: Page, server_url: str) -> None:
"""Record Live Stats page demo."""
page = wide_recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 1000)
# Navigate to Live Stats via command palette
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "live", delay=100)
pause(page, 500)
page.keyboard.press("Enter")
page.wait_for_url("**/live-stats", timeout=5000)
# Wait for containers to load (may take ~10s on first load due to SSH)
page.wait_for_selector("#container-rows tr:not(:has(.loading))", timeout=30000)
pause(page, 2000) # Let viewer see the full table with timer
# Demonstrate filtering
slow_type(page, "#filter-input", "grocy", delay=100)
pause(page, 1500) # Show filtered results
# Clear filter
page.fill("#filter-input", "")
pause(page, 1000)
# Sort by memory (click header)
page.click("th:has-text('Mem')")
pause(page, 1500)
# Sort by CPU
page.click("th:has-text('CPU')")
pause(page, 1500)
# Sort by host
page.click("th:has-text('Host')")
pause(page, 1500)
# Watch auto-refresh timer count down
pause(page, 3500) # Wait for refresh to happen
# Hover on action menu to show pause behavior
action_btn = page.locator('button[onclick^="openActionMenu"]').first
action_btn.scroll_into_view_if_needed()
action_btn.hover()
pause(page, 2000) # Show paused state (timer shows ⏸) and action menu
# Move away to close menu and resume refresh
page.locator("h2").first.hover() # Move to header
pause(page, 3500) # Watch countdown resume and refresh happen
# Final pause
pause(page, 1000)

View File

@@ -1,9 +1,11 @@
"""Demo: Container shell exec.
"""Demo: Container shell exec via command palette.
Records a ~25 second demo showing:
- Navigating to a stack page
- Clicking Shell button on a container
- Running top command inside the container
Records a ~35 second demo showing:
- Navigating to immich stack (multiple containers)
- Using command palette with fuzzy matching ("sh mach") to open shell
- Running a command
- Using command palette to switch to server container shell
- Running another command
Run: pytest docs/demos/web/demo_shell.py -v --no-cov
"""
@@ -14,6 +16,7 @@ from typing import TYPE_CHECKING
import pytest
from conftest import (
open_command_palette,
pause,
slow_type,
wait_for_sidebar,
@@ -33,39 +36,71 @@ def test_demo_shell(recording_page: Page, server_url: str) -> None:
wait_for_sidebar(page)
pause(page, 800)
# Navigate to a stack with a running container (grocy)
page.locator("#sidebar-stacks a", has_text="grocy").click()
page.wait_for_url("**/stack/grocy", timeout=5000)
# Navigate to immich via command palette (has multiple containers)
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "immich", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/immich", timeout=5000)
pause(page, 1500)
# Wait for containers list to load (loaded via HTMX)
# Wait for containers list to load (so shell commands are available)
page.wait_for_selector("#containers-list button", timeout=10000)
pause(page, 800)
# Click Shell button on the first container
shell_btn = page.locator("#containers-list button", has_text="Shell").first
shell_btn.click()
# Use command palette with fuzzy matching: "sh mach" -> "Shell: immich-machine-learning"
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "sh mach", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
pause(page, 1000)
# Wait for exec terminal to appear
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
# Scroll down to make the terminal visible
page.locator("#exec-terminal").scroll_into_view_if_needed()
pause(page, 2000)
# Smoothly scroll down to make the terminal visible
page.evaluate("""
const terminal = document.getElementById('exec-terminal');
if (terminal) {
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200)
# Run top command
slow_type(page, "#exec-terminal .xterm-helper-textarea", "top", delay=100)
# Run python version command
slow_type(page, "#exec-terminal .xterm-helper-textarea", "python3 --version", delay=60)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 4000) # Let top run for a bit
pause(page, 1500)
# Press q to quit top
page.keyboard.press("q")
# Blur the terminal to release focus (won't scroll)
page.evaluate("document.activeElement?.blur()")
pause(page, 500)
# Use command palette to switch to server container: "sh serv" -> "Shell: immich-server"
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "sh serv", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
pause(page, 1000)
# Run another command to show it's interactive
slow_type(page, "#exec-terminal .xterm-helper-textarea", "ps aux | head", delay=60)
# Wait for new terminal
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
# Scroll to terminal
page.evaluate("""
const terminal = document.getElementById('exec-terminal');
if (terminal) {
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200)
# Run ls command
slow_type(page, "#exec-terminal .xterm-helper-textarea", "ls /usr/src/app", delay=60)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 2000)

View File

@@ -55,9 +55,14 @@ def test_demo_stack(recording_page: Page, server_url: str) -> None:
page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000)
pause(page, 2000) # Let viewer see the compose file
# Scroll down slightly to show more of the editor
page.locator("#compose-editor").scroll_into_view_if_needed()
pause(page, 1500)
# Smoothly scroll down to show more of the editor
page.evaluate("""
const editor = document.getElementById('compose-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Close the compose file section
compose_collapse.locator("input[type=checkbox]").click(force=True)

View File

@@ -5,7 +5,7 @@ Records a comprehensive demo (~60 seconds) combining all major features:
2. Editor showing Compose Farm YAML config
3. Command palette navigation to grocy stack
4. Stack actions: up, logs
5. Switch to mealie stack via command palette, run update
5. Switch to dozzle stack via command palette, run update
6. Dashboard overview
7. Theme cycling via command palette
@@ -126,13 +126,13 @@ def _demo_stack_actions(page: Page) -> None:
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
# Switch to mealie via command palette
# Switch to dozzle via command palette (on nas for lower latency)
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "mealie", delay=100)
slow_type(page, "#cmd-input", "dozzle", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/mealie", timeout=5000)
page.wait_for_url("**/stack/dozzle", timeout=5000)
pause(page, 1000)
# Run update action
@@ -162,32 +162,20 @@ def _demo_dashboard_and_themes(page: Page, server_url: str) -> None:
page.evaluate("window.scrollTo(0, 0)")
pause(page, 600)
# Open theme picker and arrow down to Luxury (shows live preview)
# Theme order: light, dark, cupcake, bumblebee, emerald, corporate, synthwave,
# retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel,
# fantasy, wireframe, black, luxury (index 19)
# Open theme picker and arrow down to Dracula (shows live preview)
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 400)
# Arrow down through themes with live preview until we reach Luxury
# Arrow down through themes with live preview until we reach Dracula
for _ in range(19):
page.keyboard.press("ArrowDown")
pause(page, 180)
# Select Luxury theme
# Select Dracula theme and end on it
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 1000)
# Return to dark theme
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 300)
slow_type(page, "#cmd-input", " dark", delay=80)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 1000)
pause(page, 1500)
@pytest.mark.browser # type: ignore[misc]

View File

@@ -37,6 +37,7 @@ DEMOS = [
"workflow",
"console",
"shell",
"live_stats",
]
# High-quality ffmpeg settings for VP8 encoding
@@ -88,9 +89,9 @@ def patch_playwright_video_quality() -> None:
console.print("[green]Patched Playwright for high-quality video recording[/green]")
def record_demo(name: str) -> Path | None:
def record_demo(name: str, index: int, total: int) -> Path | None:
"""Run a single demo and return the video path."""
console.print(f"[green]Recording:[/green] web-{name}")
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] web-{name}")
demo_file = SCRIPT_DIR / f"demo_{name}.py"
if not demo_file.exists():
@@ -227,9 +228,7 @@ def main() -> int:
try:
for i, demo in enumerate(demos_to_record, 1):
console.print(f"[yellow]=== Demo {i}/{len(demos_to_record)}: {demo} ===[/yellow]")
video_path = record_demo(demo)
video_path = record_demo(demo, i, len(demos_to_record))
if video_path:
webm, gif = move_recording(video_path, demo)
results[demo] = (webm, gif)

View File

@@ -54,6 +54,25 @@ docker run --rm \
ghcr.io/basnijholt/compose-farm up --all
```
**Running as non-root user** (recommended for NFS mounts):
By default, containers run as root. To preserve file ownership on mounted volumes, set these environment variables in your `.env` file:
```bash
# Add to .env file (one-time setup)
echo "CF_UID=$(id -u)" >> .env
echo "CF_GID=$(id -g)" >> .env
echo "CF_HOME=$HOME" >> .env
echo "CF_USER=$USER" >> .env
```
Or use [direnv](https://direnv.net/) to auto-set these variables when entering the directory:
```bash
cp .envrc.example .envrc && direnv allow
```
This ensures files like `compose-farm-state.yaml` and web UI edits are owned by your user instead of root. The `CF_USER` variable is required for SSH to work when running as a non-root user.
### Verify Installation
```bash

View File

@@ -51,10 +51,32 @@ Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy sear
### Dashboard (`/`)
- Stack overview with status indicators
- Host statistics
- Host statistics (CPU, memory, disk, load via Glances)
- Pending operations (migrations, orphaned stacks)
- Quick actions via command palette
### Live Stats (`/live-stats`)
Real-time container monitoring across all hosts, powered by [Glances](https://nicolargo.github.io/glances/).
- **Live metrics**: CPU, memory, network I/O for every container
- **Auto-refresh**: Updates every 3 seconds (pauses when dropdown menus are open)
- **Filtering**: Type to filter containers by name, stack, host, or image
- **Sorting**: Click column headers to sort by any metric
- **Update detection**: Shows when container images have updates available
<video autoplay loop muted playsinline>
<source src="/assets/web-live_stats.webm" type="video/webm">
</video>
#### Requirements
Live Stats requires Glances to be deployed on all hosts:
1. Add `glances_stack: glances` to your `compose-farm.yaml`
2. Deploy a Glances stack that runs on all hosts (see [example](https://github.com/basnijholt/compose-farm/tree/main/examples/glances))
3. Glances must expose its REST API on port 61208
### Stack Detail (`/stack/{name}`)
- Compose file editor (Monaco)
@@ -63,6 +85,8 @@ Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy sear
- Container shell access (exec into running containers)
- Terminal output for running commands
Files are automatically backed up before saving to `~/.config/compose-farm/backups/`.
### Console (`/console`)
- Full shell access to any host

View File

@@ -45,6 +45,14 @@ doc:
kill-doc:
lsof -ti :9002 | xargs kill -9 2>/dev/null || true
# Record CLI demos (all or specific: just record-cli quickstart)
record-cli *demos:
python docs/demos/cli/record.py {{demos}}
# Record web UI demos (all or specific: just record-web navigation)
record-web *demos:
python docs/demos/web/record.py {{demos}}
# Clean up build artifacts and caches
clean:
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build

View File

@@ -53,6 +53,7 @@ web = [
"fastapi[standard]>=0.109.0",
"jinja2>=3.1.0",
"websockets>=12.0",
"humanize>=4.0.0",
]
[project.urls]

View File

@@ -3,10 +3,13 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated
from typing import TYPE_CHECKING, Annotated
import typer
if TYPE_CHECKING:
from compose_farm.config import Config
from compose_farm.cli.app import app
from compose_farm.cli.common import (
AllOption,
@@ -23,9 +26,14 @@ from compose_farm.cli.common import (
validate_host_for_stack,
validate_stacks,
)
from compose_farm.cli.management import _discover_stacks_full
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
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.operations import (
stop_orphaned_stacks,
stop_stray_stacks,
up_stacks,
)
from compose_farm.state import (
get_orphaned_stacks,
get_stack_host,
@@ -208,8 +216,23 @@ def update(
report_results(results)
def _discover_strays(cfg: Config) -> dict[str, list[str]]:
"""Discover stacks running on unauthorized hosts by scanning all hosts."""
_, strays, duplicates = _discover_stacks_full(cfg)
# Merge duplicates into strays (for single-host stacks on multiple hosts,
# keep correct host and stop others)
for stack, running_hosts in duplicates.items():
configured = cfg.get_hosts(stack)[0]
stray_hosts = [h for h in running_hosts if h != configured]
if stray_hosts:
strays[stack] = stray_hosts
return strays
@app.command(rich_help_panel="Lifecycle")
def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
def apply( # noqa: C901, PLR0912, PLR0915 (multi-phase reconciliation needs these branches)
dry_run: Annotated[
bool,
typer.Option("--dry-run", "-n", help="Show what would change without executing"),
@@ -218,23 +241,29 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
bool,
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned stacks"),
] = False,
no_strays: Annotated[
bool,
typer.Option("--no-strays", help="Don't stop stray stacks (running on wrong host)"),
] = False,
full: Annotated[
bool,
typer.Option("--full", "-f", help="Also run up on all stacks to apply config changes"),
] = False,
config: ConfigOption = None,
) -> None:
"""Make reality match config (start, migrate, stop as needed).
"""Make reality match config (start, migrate, stop strays/orphans as needed).
This is the "reconcile" command that ensures running stacks match your
config file. It will:
1. Stop orphaned stacks (in state but removed from config)
2. Migrate stacks on wrong host (host in state ≠ host in config)
3. Start missing stacks (in config but not in state)
2. Stop stray stacks (running on unauthorized hosts)
3. Migrate stacks on wrong host (host in state ≠ host in config)
4. Start missing stacks (in config but not in state)
Use --dry-run to preview changes before applying.
Use --no-orphans to only migrate/start without stopping orphaned stacks.
Use --no-orphans to skip stopping orphaned stacks.
Use --no-strays to skip stopping stray stacks.
Use --full to also run 'up' on all stacks (picks up compose/env changes).
"""
cfg = load_config_or_exit(config)
@@ -242,16 +271,28 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
migrations = get_stacks_needing_migration(cfg)
missing = get_stacks_not_in_state(cfg)
strays: dict[str, list[str]] = {}
if not no_strays:
console.print("[dim]Scanning hosts for stray containers...[/]")
strays = _discover_strays(cfg)
# For --full: refresh all stacks not already being started/migrated
handled = set(migrations) | set(missing)
to_refresh = [stack for stack in cfg.stacks if stack not in handled] if full else []
has_orphans = bool(orphaned) and not no_orphans
has_strays = bool(strays)
has_migrations = bool(migrations)
has_missing = bool(missing)
has_refresh = bool(to_refresh)
if not has_orphans and not has_migrations and not has_missing and not has_refresh:
if (
not has_orphans
and not has_strays
and not has_migrations
and not has_missing
and not has_refresh
):
print_success("Nothing to apply - reality matches config")
return
@@ -260,6 +301,14 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
console.print(f"[yellow]Orphaned stacks to stop ({len(orphaned)}):[/]")
for svc, hosts in orphaned.items():
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
if has_strays:
console.print(f"[red]Stray stacks to stop ({len(strays)}):[/]")
for stack, hosts in strays.items():
configured = cfg.get_hosts(stack)
console.print(
f" [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
f"[dim](should be on {', '.join(configured)})[/]"
)
if has_migrations:
console.print(f"[cyan]Stacks to migrate ({len(migrations)}):[/]")
for stack in migrations:
@@ -288,21 +337,26 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
console.print("[yellow]Stopping orphaned stacks...[/]")
all_results.extend(run_async(stop_orphaned_stacks(cfg)))
# 2. Migrate stacks on wrong host
# 2. Stop stray stacks (running on unauthorized hosts)
if has_strays:
console.print("[red]Stopping stray stacks...[/]")
all_results.extend(run_async(stop_stray_stacks(cfg, strays)))
# 3. Migrate stacks on wrong host
if has_migrations:
console.print("[cyan]Migrating stacks...[/]")
migrate_results = run_async(up_stacks(cfg, migrations, raw=True))
all_results.extend(migrate_results)
maybe_regenerate_traefik(cfg, migrate_results)
# 3. Start missing stacks (reuse up_stacks which handles state updates)
# 4. Start missing stacks (reuse up_stacks which handles state updates)
if has_missing:
console.print("[green]Starting missing stacks...[/]")
start_results = run_async(up_stacks(cfg, missing, raw=True))
all_results.extend(start_results)
maybe_regenerate_traefik(cfg, start_results)
# 4. Refresh remaining stacks (--full: run up to apply config changes)
# 5. Refresh remaining stacks (--full: run up to apply config changes)
if has_refresh:
console.print("[blue]Refreshing stacks...[/]")
refresh_results = run_async(up_stacks(cfg, to_refresh, raw=True))

View File

@@ -37,22 +37,23 @@ from compose_farm.console import (
)
from compose_farm.executor import (
CommandResult,
get_running_stacks_on_host,
is_local,
run_command,
)
from compose_farm.logs import (
DEFAULT_LOG_PATH,
SnapshotEntry,
collect_stack_entries,
collect_stacks_entries_on_host,
isoformat,
load_existing_entries,
merge_entries,
write_toml,
)
from compose_farm.operations import (
build_discovery_results,
check_host_compatibility,
check_stack_requirements,
discover_stack_host,
)
from compose_farm.state import get_orphaned_stacks, load_state, save_state
from compose_farm.traefik import generate_traefik_config, render_traefik_config
@@ -60,38 +61,39 @@ from compose_farm.traefik import generate_traefik_config, render_traefik_config
# --- Sync helpers ---
def _discover_stacks(cfg: Config, stacks: list[str] | None = None) -> dict[str, str | list[str]]:
"""Discover running stacks with a progress bar."""
stack_list = stacks if stacks is not None else list(cfg.stacks)
results = run_parallel_with_progress(
"Discovering",
stack_list,
lambda s: discover_stack_host(cfg, s),
)
return {svc: host for svc, host in results if host is not None}
def _snapshot_stacks(
cfg: Config,
stacks: list[str],
discovered: dict[str, str | list[str]],
log_path: Path | None,
) -> Path:
"""Capture image digests with a progress bar."""
"""Capture image digests using batched SSH calls (1 per host).
Args:
cfg: Configuration
discovered: Dict mapping stack -> host(s) where it's running
log_path: Optional path to write the log file
Returns:
Path to the written log file.
"""
effective_log_path = log_path or DEFAULT_LOG_PATH
now_dt = datetime.now(UTC)
now_iso = isoformat(now_dt)
async def collect_stack(stack: str) -> tuple[str, list[SnapshotEntry]]:
try:
return stack, await collect_stack_entries(cfg, stack, now=now_dt)
except RuntimeError:
return stack, []
# Group stacks by host for batched SSH calls
stacks_by_host: dict[str, set[str]] = {}
for stack, hosts in discovered.items():
# Use first host for multi-host stacks (they use the same images)
host = hosts[0] if isinstance(hosts, list) else hosts
stacks_by_host.setdefault(host, set()).add(stack)
results = run_parallel_with_progress(
"Capturing",
stacks,
collect_stack,
)
# Collect entries with 1 SSH call per host (with progress bar)
async def collect_on_host(host: str) -> tuple[str, list[SnapshotEntry]]:
entries = await collect_stacks_entries_on_host(cfg, host, stacks_by_host[host], now=now_dt)
return host, entries
results = run_parallel_with_progress("Capturing", list(stacks_by_host.keys()), collect_on_host)
snapshot_entries = [entry for _, entries in results for entry in entries]
if not snapshot_entries:
@@ -147,6 +149,61 @@ def _report_sync_changes(
console.print(f" [red]-[/] [cyan]{stack}[/] (was on [magenta]{host_str}[/])")
def _discover_stacks_full(
cfg: Config,
stacks: list[str] | None = None,
) -> tuple[dict[str, str | list[str]], dict[str, list[str]], dict[str, list[str]]]:
"""Discover running stacks with full host scanning for stray detection.
Queries each host once for all running stacks (with progress bar),
then delegates to build_discovery_results for categorization.
"""
all_hosts = list(cfg.hosts.keys())
# Query each host for running stacks (with progress bar)
async def get_stacks_on_host(host: str) -> tuple[str, set[str]]:
running = await get_running_stacks_on_host(cfg, host)
return host, running
host_results = run_parallel_with_progress("Discovering", all_hosts, get_stacks_on_host)
running_on_host: dict[str, set[str]] = dict(host_results)
return build_discovery_results(cfg, running_on_host, stacks)
def _report_stray_stacks(
strays: dict[str, list[str]],
cfg: Config,
) -> None:
"""Report stacks running on unauthorized hosts."""
if strays:
console.print(f"\n[red]Stray stacks[/] (running on wrong host, {len(strays)}):")
console.print("[dim]Run [bold]cf apply[/bold] to stop them.[/]")
for stack in sorted(strays):
stray_hosts = strays[stack]
configured = cfg.get_hosts(stack)
console.print(
f" [red]![/] [cyan]{stack}[/] on [magenta]{', '.join(stray_hosts)}[/] "
f"[dim](should be on {', '.join(configured)})[/]"
)
def _report_duplicate_stacks(duplicates: dict[str, list[str]], cfg: Config) -> None:
"""Report single-host stacks running on multiple hosts."""
if duplicates:
console.print(
f"\n[yellow]Duplicate stacks[/] (running on multiple hosts, {len(duplicates)}):"
)
console.print("[dim]Run [bold]cf apply[/bold] to stop extras.[/]")
for stack in sorted(duplicates):
hosts = duplicates[stack]
configured = cfg.get_hosts(stack)[0]
console.print(
f" [yellow]![/] [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
f"[dim](should only be on {configured})[/]"
)
# --- Check helpers ---
@@ -440,7 +497,7 @@ def refresh(
current_state = load_state(cfg)
discovered = _discover_stacks(cfg, stack_list)
discovered, strays, duplicates = _discover_stacks_full(cfg, stack_list)
# Calculate changes (only for the stacks we're refreshing)
added = [s for s in discovered if s not in current_state]
@@ -463,6 +520,9 @@ def refresh(
else:
print_success("State is already in sync.")
_report_stray_stacks(strays, cfg)
_report_duplicate_stacks(duplicates, cfg)
if dry_run:
console.print(f"\n{MSG_DRY_RUN}")
return
@@ -475,10 +535,10 @@ def refresh(
save_state(cfg, new_state)
print_success(f"State updated: {len(new_state)} stacks tracked.")
# Capture image digests for running stacks
# Capture image digests for running stacks (1 SSH call per host)
if discovered:
try:
path = _snapshot_stacks(cfg, list(discovered.keys()), log_path)
path = _snapshot_stacks(cfg, discovered, log_path)
print_success(f"Digests written to {path}")
except RuntimeError as exc:
print_warning(str(exc))

View File

@@ -31,6 +31,9 @@ class Config(BaseModel, extra="forbid"):
stacks: dict[str, str | list[str]] # stack_name -> host_name or list of hosts
traefik_file: Path | None = None # Auto-regenerate traefik config after up/down
traefik_stack: str | None = None # Stack name for Traefik (skip its host in file-provider)
glances_stack: str | None = (
None # Stack name for Glances (enables host resource stats in web UI)
)
config_path: Path = Path() # Set by load_config()
def get_state_path(self) -> Path:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import socket
import subprocess
import time
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any
@@ -23,6 +24,38 @@ LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
_DEFAULT_SSH_PORT = 22
class TTLCache:
"""Simple TTL cache for async function results."""
def __init__(self, ttl_seconds: float = 30.0) -> None:
"""Initialize cache with default TTL in seconds."""
# Cache stores: key -> (timestamp, value, item_ttl)
self._cache: dict[str, tuple[float, Any, float]] = {}
self._default_ttl = ttl_seconds
def get(self, key: str) -> Any | None:
"""Get value if exists and not expired."""
if key in self._cache:
timestamp, value, item_ttl = self._cache[key]
if time.monotonic() - timestamp < item_ttl:
return value
del self._cache[key]
return None
def set(self, key: str, value: Any, ttl_seconds: float | None = None) -> None:
"""Set value with current timestamp and optional custom TTL."""
ttl = ttl_seconds if ttl_seconds is not None else self._default_ttl
self._cache[key] = (time.monotonic(), value, ttl)
def clear(self) -> None:
"""Clear all cached values."""
self._cache.clear()
# Cache compose labels per host for 30 seconds
_compose_labels_cache = TTLCache(ttl_seconds=30.0)
def _print_compose_command(
host_name: str,
compose_dir: str,
@@ -158,15 +191,20 @@ def ssh_connect_kwargs(host: Host) -> dict[str, Any]:
"port": host.port,
"username": host.user,
"known_hosts": None,
"gss_auth": False, # Disable GSSAPI - causes multi-second delays
}
# Add SSH agent path (auto-detect forwarded agent if needed)
agent_path = get_ssh_auth_sock()
if agent_path:
kwargs["agent_path"] = agent_path
# Add key file fallback for when SSH agent is unavailable
# Add key file fallback (prioritized over agent if present)
key_path = get_key_path()
agent_path = get_ssh_auth_sock()
if key_path:
# If dedicated key exists, force use of it and ignore agent
# This avoids issues with stale/broken forwarded agents in Docker
kwargs["client_keys"] = [str(key_path)]
elif agent_path:
# Fallback to agent if no dedicated key
kwargs["agent_path"] = agent_path
return kwargs
@@ -497,6 +535,72 @@ async def check_stack_running(
return result.success and bool(result.stdout.strip())
async def get_running_stacks_on_host(
config: Config,
host_name: str,
) -> set[str]:
"""Get all running compose stacks on a host in a single SSH call.
Uses docker ps with the compose.project label to identify running stacks.
Much more efficient than checking each stack individually.
"""
host = config.hosts[host_name]
# Get unique project names from running containers
command = "docker ps --format '{{.Label \"com.docker.compose.project\"}}' | sort -u"
result = await run_command(host, command, stack=host_name, stream=False, prefix="")
if not result.success:
return set()
# Filter out empty lines and return as set
return {line.strip() for line in result.stdout.splitlines() if line.strip()}
async def get_container_compose_labels(
config: Config,
host_name: str,
) -> dict[str, tuple[str, str]]:
"""Get compose labels for all containers on a host.
Returns dict of container_name -> (project, service).
Includes all containers (-a flag) since Glances shows stopped containers too.
Falls back to empty dict on timeout/error (5s timeout).
Results are cached for 30 seconds to reduce SSH overhead.
"""
# Check cache first
cached: dict[str, tuple[str, str]] | None = _compose_labels_cache.get(host_name)
if cached is not None:
return cached
host = config.hosts[host_name]
cmd = (
"docker ps -a --format "
'\'{{.Names}}\t{{.Label "com.docker.compose.project"}}\t'
'{{.Label "com.docker.compose.service"}}\''
)
try:
async with asyncio.timeout(5.0):
result = await run_command(host, cmd, stack=host_name, stream=False, prefix="")
except TimeoutError:
return {}
except Exception:
return {}
labels: dict[str, tuple[str, str]] = {}
if result.success:
for line in result.stdout.splitlines():
parts = line.strip().split("\t")
if len(parts) >= 3: # noqa: PLR2004
name, project, service = parts[0], parts[1], parts[2]
labels[name] = (project or "", service or "")
# Cache the result
_compose_labels_cache.set(host_name, labels)
return labels
async def _batch_check_existence(
config: Config,
host_name: str,

236
src/compose_farm/glances.py Normal file
View File

@@ -0,0 +1,236 @@
"""Glances API client for host resource monitoring."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .config import Config
# Default Glances REST API port
DEFAULT_GLANCES_PORT = 61208
@dataclass
class HostStats:
"""Resource statistics for a host."""
host: str
cpu_percent: float
mem_percent: float
swap_percent: float
load: float
disk_percent: float
net_rx_rate: float = 0.0 # bytes/sec
net_tx_rate: float = 0.0 # bytes/sec
error: str | None = None
@classmethod
def from_error(cls, host: str, error: str) -> HostStats:
"""Create a HostStats with an error."""
return cls(
host=host,
cpu_percent=0,
mem_percent=0,
swap_percent=0,
load=0,
disk_percent=0,
net_rx_rate=0,
net_tx_rate=0,
error=error,
)
async def fetch_host_stats(
host_name: str,
host_address: str,
port: int = DEFAULT_GLANCES_PORT,
request_timeout: float = 10.0,
) -> HostStats:
"""Fetch stats from a single host's Glances API."""
import httpx # noqa: PLC0415
base_url = f"http://{host_address}:{port}/api/4"
try:
async with httpx.AsyncClient(timeout=request_timeout) as client:
# Fetch quicklook stats (CPU, mem, load)
response = await client.get(f"{base_url}/quicklook")
if not response.is_success:
return HostStats.from_error(host_name, f"HTTP {response.status_code}")
data = response.json()
# Fetch filesystem stats for disk usage (root fs or max across all)
disk_percent = 0.0
try:
fs_response = await client.get(f"{base_url}/fs")
if fs_response.is_success:
fs_data = fs_response.json()
root = next((fs for fs in fs_data if fs.get("mnt_point") == "/"), None)
disk_percent = (
root.get("percent", 0)
if root
else max((fs.get("percent", 0) for fs in fs_data), default=0)
)
except httpx.HTTPError:
pass # Disk stats are optional
# Fetch network stats for rate (sum across non-loopback interfaces)
net_rx_rate, net_tx_rate = 0.0, 0.0
try:
net_response = await client.get(f"{base_url}/network")
if net_response.is_success:
for iface in net_response.json():
if not iface.get("interface_name", "").startswith("lo"):
net_rx_rate += iface.get("bytes_recv_rate_per_sec") or 0
net_tx_rate += iface.get("bytes_sent_rate_per_sec") or 0
except httpx.HTTPError:
pass # Network stats are optional
return HostStats(
host=host_name,
cpu_percent=data.get("cpu", 0),
mem_percent=data.get("mem", 0),
swap_percent=data.get("swap", 0),
load=data.get("load", 0),
disk_percent=disk_percent,
net_rx_rate=net_rx_rate,
net_tx_rate=net_tx_rate,
)
except httpx.TimeoutException:
return HostStats.from_error(host_name, "timeout")
except httpx.HTTPError as e:
return HostStats.from_error(host_name, str(e))
except Exception as e:
return HostStats.from_error(host_name, str(e))
async def fetch_all_host_stats(
config: Config,
port: int = DEFAULT_GLANCES_PORT,
) -> dict[str, HostStats]:
"""Fetch stats from all hosts in parallel."""
tasks = [fetch_host_stats(name, host.address, port) for name, host in config.hosts.items()]
results = await asyncio.gather(*tasks)
return {stats.host: stats for stats in results}
@dataclass
class ContainerStats:
"""Container statistics from Glances."""
name: str
host: str
status: str
image: str
cpu_percent: float
memory_usage: int # bytes
memory_limit: int # bytes
memory_percent: float
network_rx: int # cumulative bytes received
network_tx: int # cumulative bytes sent
uptime: str
ports: str
engine: str # docker, podman, etc.
stack: str = "" # compose project name (from docker labels)
service: str = "" # compose service name (from docker labels)
def _parse_container(data: dict[str, Any], host_name: str) -> ContainerStats:
"""Parse container data from Glances API response."""
# Image can be a list or string
image = data.get("image", ["unknown"])
if isinstance(image, list):
image = image[0] if image else "unknown"
# Calculate memory percent
mem_usage = data.get("memory_usage", 0) or 0
mem_limit = data.get("memory_limit", 1) or 1 # Avoid division by zero
mem_percent = (mem_usage / mem_limit) * 100 if mem_limit > 0 else 0
# Network stats
network = data.get("network", {}) or {}
network_rx = network.get("cumulative_rx", 0) or 0
network_tx = network.get("cumulative_tx", 0) or 0
return ContainerStats(
name=data.get("name", "unknown"),
host=host_name,
status=data.get("status", "unknown"),
image=image,
cpu_percent=data.get("cpu_percent", 0) or 0,
memory_usage=mem_usage,
memory_limit=mem_limit,
memory_percent=mem_percent,
network_rx=network_rx,
network_tx=network_tx,
uptime=data.get("uptime", ""),
ports=data.get("ports", "") or "",
engine=data.get("engine", "docker"),
)
async def fetch_container_stats(
host_name: str,
host_address: str,
port: int = DEFAULT_GLANCES_PORT,
request_timeout: float = 10.0,
) -> tuple[list[ContainerStats] | None, str | None]:
"""Fetch container stats from a single host's Glances API.
Returns:
(containers, error_message)
- Success: ([...], None)
- Failure: (None, "error message")
"""
import httpx # noqa: PLC0415
url = f"http://{host_address}:{port}/api/4/containers"
try:
async with httpx.AsyncClient(timeout=request_timeout) as client:
response = await client.get(url)
if not response.is_success:
return None, f"HTTP {response.status_code}: {response.reason_phrase}"
data = response.json()
return [_parse_container(c, host_name) for c in data], None
except httpx.ConnectError:
return None, "Connection refused (Glances offline?)"
except httpx.TimeoutException:
return None, "Connection timed out"
except Exception as e:
return None, str(e)
async def fetch_all_container_stats(
config: Config,
port: int = DEFAULT_GLANCES_PORT,
) -> list[ContainerStats]:
"""Fetch container stats from all hosts in parallel, enriched with compose labels."""
from .executor import get_container_compose_labels # noqa: PLC0415
async def fetch_host_data(
host_name: str,
host_address: str,
) -> list[ContainerStats]:
# Fetch Glances stats and compose labels in parallel
stats_task = fetch_container_stats(host_name, host_address, port)
labels_task = get_container_compose_labels(config, host_name)
(containers, _), labels = await asyncio.gather(stats_task, labels_task)
if containers is None:
# Skip failed hosts in aggregate view
return []
# Enrich containers with compose labels (mutate in place)
for c in containers:
c.stack, c.service = labels.get(c.name, ("", ""))
return containers
tasks = [fetch_host_data(name, host.address) for name, host in config.hosts.items()]
results = await asyncio.gather(*tasks)
# Flatten list of lists
return [container for host_containers in results for container in host_containers]

View File

@@ -6,21 +6,22 @@ import json
import tomllib
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from .executor import run_compose
from .executor import run_command
from .paths import xdg_config_home
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Iterable
from pathlib import Path
from .config import Config
from .executor import CommandResult
# Separator used to split output sections
_SECTION_SEPARATOR = "---CF-SEP---"
DEFAULT_LOG_PATH = xdg_config_home() / "compose-farm" / "dockerfarm-log.toml"
_DIGEST_HEX_LENGTH = 64
@dataclass(frozen=True)
@@ -56,87 +57,97 @@ def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _parse_images_output(raw: str) -> list[dict[str, Any]]:
"""Parse `docker compose images --format json` output.
Handles both a JSON array and newline-separated JSON objects for robustness.
"""
raw = raw.strip()
if not raw:
return []
def _parse_image_digests(image_json: str) -> dict[str, str]:
"""Parse docker image inspect JSON to build image tag -> digest map."""
if not image_json:
return {}
try:
parsed = json.loads(raw)
image_data = json.loads(image_json)
except json.JSONDecodeError:
objects = []
for line in raw.splitlines():
if not line.strip():
continue
objects.append(json.loads(line))
return objects
return {}
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict):
return [parsed]
return []
image_digests: dict[str, str] = {}
for img in image_data:
tags = img.get("RepoTags") or []
digests = img.get("RepoDigests") or []
digest = digests[0].split("@")[-1] if digests else img.get("Id", "")
for tag in tags:
image_digests[tag] = digest
if img.get("Id"):
image_digests[img["Id"]] = digest
return image_digests
def _extract_image_fields(record: dict[str, Any]) -> tuple[str, str]:
"""Extract image name and digest with fallbacks."""
image = record.get("Image") or record.get("Repository") or record.get("Name") or ""
tag = record.get("Tag") or record.get("Version")
if tag and ":" not in image.rsplit("/", 1)[-1]:
image = f"{image}:{tag}"
digest = (
record.get("Digest")
or record.get("Image ID")
or record.get("ImageID")
or record.get("ID")
or ""
)
if digest and not digest.startswith("sha256:") and len(digest) == _DIGEST_HEX_LENGTH:
digest = f"sha256:{digest}"
return image, digest
async def collect_stack_entries(
async def collect_stacks_entries_on_host(
config: Config,
stack: str,
host_name: str,
stacks: set[str],
*,
now: datetime,
run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
) -> list[SnapshotEntry]:
"""Run `docker compose images` for a stack and normalize results."""
result = await run_compose_fn(config, stack, "images --format json", stream=False)
"""Collect image entries for stacks on one host using 2 docker commands.
Uses `docker ps` to get running containers + their compose project labels,
then `docker image inspect` to get digests for all unique images.
Much faster than running N `docker compose images` commands.
"""
if not stacks:
return []
host = config.hosts[host_name]
# Single SSH call with 2 docker commands:
# 1. Get project|image pairs from running containers
# 2. Get image info (including digests) for all unique images
command = (
f"docker ps --format '{{{{.Label \"com.docker.compose.project\"}}}}|{{{{.Image}}}}' && "
f"echo '{_SECTION_SEPARATOR}' && "
"docker image inspect $(docker ps --format '{{.Image}}' | sort -u) 2>/dev/null || true"
)
result = await run_command(host, command, host_name, stream=False, prefix="")
if not result.success:
msg = result.stderr or f"compose images exited with {result.exit_code}"
error = f"[{stack}] Unable to read images: {msg}"
raise RuntimeError(error)
return []
records = _parse_images_output(result.stdout)
# Use first host for snapshots (multi-host stacks use same images on all hosts)
host_name = config.get_hosts(stack)[0]
compose_path = config.get_compose_path(stack)
# Split output into two sections
parts = result.stdout.split(_SECTION_SEPARATOR)
if len(parts) != 2: # noqa: PLR2004
return []
entries: list[SnapshotEntry] = []
for record in records:
image, digest = _extract_image_fields(record)
if not digest:
container_lines, image_json = parts[0].strip(), parts[1].strip()
# Parse project|image pairs, filtering to only stacks we care about
stack_images: dict[str, set[str]] = {}
for line in container_lines.splitlines():
if "|" not in line:
continue
entries.append(
SnapshotEntry(
stack=stack,
host=host_name,
compose_file=compose_path,
image=image,
digest=digest,
captured_at=now,
)
)
project, image = line.split("|", 1)
if project in stacks:
stack_images.setdefault(project, set()).add(image)
if not stack_images:
return []
# Parse image inspect JSON to build image -> digest map
image_digests = _parse_image_digests(image_json)
# Build entries
entries: list[SnapshotEntry] = []
for stack, images in stack_images.items():
for image in images:
digest = image_digests.get(image, "")
if digest:
entries.append(
SnapshotEntry(
stack=stack,
host=host_name,
compose_file=config.get_compose_path(stack),
image=image,
digest=digest,
captured_at=now,
)
)
return entries

View File

@@ -76,29 +76,37 @@ def get_stack_paths(cfg: Config, stack: str) -> list[str]:
return paths
async def discover_stack_host(cfg: Config, stack: str) -> tuple[str, str | list[str] | None]:
"""Discover where a stack is running.
class StackDiscoveryResult(NamedTuple):
"""Result of discovering where a stack is running across all hosts."""
For multi-host stacks, checks all assigned hosts in parallel.
For single-host, checks assigned host first, then others.
stack: str
configured_hosts: list[str] # From config (where it SHOULD run)
running_hosts: list[str] # From reality (where it IS running)
Returns (stack_name, host_or_hosts_or_none).
"""
assigned_hosts = cfg.get_hosts(stack)
@property
def is_multi_host(self) -> bool:
"""Check if this is a multi-host stack."""
return len(self.configured_hosts) > 1
if cfg.is_multi_host(stack):
# Check all assigned hosts in parallel
checks = await asyncio.gather(*[check_stack_running(cfg, stack, h) for h in assigned_hosts])
running = [h for h, is_running in zip(assigned_hosts, checks, strict=True) if is_running]
return stack, running if running else None
@property
def stray_hosts(self) -> list[str]:
"""Hosts where stack is running but shouldn't be."""
return [h for h in self.running_hosts if h not in self.configured_hosts]
# Single-host: check assigned host first, then others
if await check_stack_running(cfg, stack, assigned_hosts[0]):
return stack, assigned_hosts[0]
for host in cfg.hosts:
if host != assigned_hosts[0] and await check_stack_running(cfg, stack, host):
return stack, host
return stack, None
@property
def missing_hosts(self) -> list[str]:
"""Hosts where stack should be running but isn't."""
return [h for h in self.configured_hosts if h not in self.running_hosts]
@property
def is_stray(self) -> bool:
"""Stack is running on unauthorized host(s)."""
return len(self.stray_hosts) > 0
@property
def is_duplicate(self) -> bool:
"""Single-host stack running on multiple hosts."""
return not self.is_multi_host and len(self.running_hosts) > 1
async def check_stack_requirements(
@@ -359,26 +367,33 @@ async def check_host_compatibility(
return results
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
"""Stop orphaned stacks (in state but not in config).
async def _stop_stacks_on_hosts(
cfg: Config,
stacks_to_hosts: dict[str, list[str]],
label: str = "",
) -> list[CommandResult]:
"""Stop stacks on specific hosts.
Runs docker compose down on each stack on its tracked host(s).
Only removes from state on successful stop.
Shared helper for stop_orphaned_stacks and stop_stray_stacks.
Args:
cfg: Config object.
stacks_to_hosts: Dict mapping stack name to list of hosts to stop on.
label: Optional label for success message (e.g., "stray", "orphaned").
Returns:
List of CommandResults for each stack@host.
Returns list of CommandResults for each stack@host.
"""
orphaned = get_orphaned_stacks(cfg)
if not orphaned:
if not stacks_to_hosts:
return []
results: list[CommandResult] = []
tasks: list[tuple[str, str, asyncio.Task[CommandResult]]] = []
suffix = f" ({label})" if label else ""
# Build list of (stack, host, task) for all orphaned stacks
for stack, hosts in orphaned.items():
host_list = hosts if isinstance(hosts, list) else [hosts]
for host in host_list:
# Skip hosts no longer in config
for stack, hosts in stacks_to_hosts.items():
for host in hosts:
if host not in cfg.hosts:
print_warning(f"{stack}@{host}: host no longer in config, skipping")
results.append(
@@ -393,30 +408,48 @@ async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
coro = run_compose_on_host(cfg, stack, host, "down")
tasks.append((stack, host, asyncio.create_task(coro)))
# Run all down commands in parallel
if tasks:
for stack, host, task in tasks:
try:
result = await task
results.append(result)
if result.success:
print_success(f"{stack}@{host}: stopped")
else:
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
except Exception as e:
print_error(f"{stack}@{host}: {e}")
results.append(
CommandResult(
stack=f"{stack}@{host}",
exit_code=1,
success=False,
stderr=str(e),
)
for stack, host, task in tasks:
try:
result = await task
results.append(result)
if result.success:
print_success(f"{stack}@{host}: stopped{suffix}")
else:
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
except Exception as e:
print_error(f"{stack}@{host}: {e}")
results.append(
CommandResult(
stack=f"{stack}@{host}",
exit_code=1,
success=False,
stderr=str(e),
)
)
return results
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
"""Stop orphaned stacks (in state but not in config).
Runs docker compose down on each stack on its tracked host(s).
Only removes from state on successful stop.
Returns list of CommandResults for each stack@host.
"""
orphaned = get_orphaned_stacks(cfg)
if not orphaned:
return []
normalized: dict[str, list[str]] = {
stack: (hosts if isinstance(hosts, list) else [hosts]) for stack, hosts in orphaned.items()
}
results = await _stop_stacks_on_hosts(cfg, normalized)
# Remove from state only for stacks where ALL hosts succeeded
for stack, hosts in orphaned.items():
host_list = hosts if isinstance(hosts, list) else [hosts]
for stack in normalized:
all_succeeded = all(
r.success for r in results if r.stack.startswith(f"{stack}@") or r.stack == stack
)
@@ -424,3 +457,77 @@ async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
remove_stack(cfg, stack)
return results
async def stop_stray_stacks(
cfg: Config,
strays: dict[str, list[str]],
) -> list[CommandResult]:
"""Stop stacks running on unauthorized hosts.
Args:
cfg: Config object.
strays: Dict mapping stack name to list of stray hosts.
Returns:
List of CommandResults for each stack@host stopped.
"""
return await _stop_stacks_on_hosts(cfg, strays, label="stray")
def build_discovery_results(
cfg: Config,
running_on_host: dict[str, set[str]],
stacks: list[str] | None = None,
) -> tuple[dict[str, str | list[str]], dict[str, list[str]], dict[str, list[str]]]:
"""Build discovery results from per-host running stacks.
Takes the raw data of which stacks are running on which hosts and
categorizes them into discovered (running correctly), strays (wrong host),
and duplicates (single-host stack on multiple hosts).
Args:
cfg: Config object.
running_on_host: Dict mapping host -> set of running stack names.
stacks: Optional list of stacks to check. Defaults to all configured stacks.
Returns:
Tuple of (discovered, strays, duplicates):
- discovered: stack -> host(s) where running correctly
- strays: stack -> list of unauthorized hosts
- duplicates: stack -> list of all hosts (for single-host stacks on multiple)
"""
stack_list = stacks if stacks is not None else list(cfg.stacks)
all_hosts = list(running_on_host.keys())
# Build StackDiscoveryResult for each stack
results: list[StackDiscoveryResult] = [
StackDiscoveryResult(
stack=stack,
configured_hosts=cfg.get_hosts(stack),
running_hosts=[h for h in all_hosts if stack in running_on_host[h]],
)
for stack in stack_list
]
discovered: dict[str, str | list[str]] = {}
strays: dict[str, list[str]] = {}
duplicates: dict[str, list[str]] = {}
for result in results:
correct_hosts = [h for h in result.running_hosts if h in result.configured_hosts]
if correct_hosts:
if result.is_multi_host:
discovered[result.stack] = correct_hosts
else:
discovered[result.stack] = correct_hosts[0]
if result.is_stray:
strays[result.stack] = result.stray_hosts
if result.is_duplicate:
duplicates[result.stack] = result.running_hosts
return discovered, strays, duplicates

View File

@@ -11,9 +11,19 @@ def xdg_config_home() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
def config_dir() -> Path:
"""Get the compose-farm config directory."""
return xdg_config_home() / "compose-farm"
def default_config_path() -> Path:
"""Get the default user config path."""
return xdg_config_home() / "compose-farm" / "compose-farm.yaml"
return config_dir() / "compose-farm.yaml"
def backup_dir() -> Path:
"""Get the backup directory for file edits."""
return config_dir() / "backups"
def config_search_paths() -> list[Path]:

View File

@@ -0,0 +1,220 @@
"""Container registry API client for tag discovery."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import httpx
# Image reference pattern: [registry/][namespace/]name[:tag][@digest]
IMAGE_PATTERN = re.compile(
r"^(?:(?P<registry>[^/]+\.[^/]+)/)?(?:(?P<namespace>[^/:@]+)/)?(?P<name>[^/:@]+)(?::(?P<tag>[^@]+))?(?:@(?P<digest>.+))?$"
)
# Docker Hub aliases
DOCKER_HUB_ALIASES = frozenset(
{"docker.io", "index.docker.io", "registry.hub.docker.com", "registry-1.docker.io"}
)
# Token endpoints per registry: (url, extra_params)
TOKEN_ENDPOINTS: dict[str, tuple[str, dict[str, str]]] = {
"docker.io": ("https://auth.docker.io/token", {"service": "registry.docker.io"}),
"ghcr.io": ("https://ghcr.io/token", {}),
}
# Registry URL overrides (Docker Hub uses a different host for API)
REGISTRY_URLS: dict[str, str] = {
"docker.io": "https://registry-1.docker.io",
}
HTTP_OK = 200
MANIFEST_ACCEPT = (
"application/vnd.docker.distribution.manifest.v2+json, "
"application/vnd.oci.image.manifest.v1+json, "
"application/vnd.oci.image.index.v1+json"
)
@dataclass(frozen=True)
class ImageRef:
"""Parsed container image reference."""
registry: str
namespace: str
name: str
tag: str
digest: str | None = None
@property
def full_name(self) -> str:
"""Full image name with namespace."""
return f"{self.namespace}/{self.name}" if self.namespace else self.name
@property
def display_name(self) -> str:
"""Display name (omits docker.io/library for official images)."""
if self.registry in DOCKER_HUB_ALIASES:
if self.namespace == "library":
return self.name
return self.full_name
return f"{self.registry}/{self.full_name}"
@classmethod
def parse(cls, image: str) -> ImageRef:
"""Parse image string into components."""
match = IMAGE_PATTERN.match(image)
if not match:
return cls("docker.io", "library", image.split(":")[0].split("@")[0], "latest")
groups = match.groupdict()
registry = groups.get("registry") or "docker.io"
namespace = groups.get("namespace") or ""
name = groups.get("name") or image
tag = groups.get("tag") or "latest"
digest = groups.get("digest")
# Docker Hub official images have implicit "library" namespace
if registry in DOCKER_HUB_ALIASES and not namespace:
namespace = "library"
return cls(registry, namespace, name, tag, digest)
@dataclass
class TagCheckResult:
"""Result of checking tags for an image."""
image: ImageRef
current_digest: str
available_updates: list[str] = field(default_factory=list)
error: str | None = None
class RegistryClient:
"""Unified OCI Distribution API client."""
def __init__(self, registry: str) -> None:
"""Initialize for a specific registry."""
self.registry = registry.lower()
# Normalize Docker Hub aliases
if self.registry in DOCKER_HUB_ALIASES:
self.registry = "docker.io"
self.registry_url = REGISTRY_URLS.get(self.registry, f"https://{self.registry}")
self._token_cache: dict[str, str] = {}
async def _get_token(self, image: ImageRef, client: httpx.AsyncClient) -> str | None:
"""Get auth token for the registry (cached per image)."""
cache_key = image.full_name
if cache_key in self._token_cache:
return self._token_cache[cache_key]
endpoint = TOKEN_ENDPOINTS.get(self.registry)
if not endpoint:
return None # No auth needed or unknown registry
url, extra_params = endpoint
params = {"scope": f"repository:{image.full_name}:pull", **extra_params}
resp = await client.get(url, params=params)
if resp.status_code == HTTP_OK:
token: str | None = resp.json().get("token")
if token:
self._token_cache[cache_key] = token
return token
return None
async def get_tags(self, image: ImageRef, client: httpx.AsyncClient) -> list[str]:
"""Fetch available tags for an image."""
headers = {}
token = await self._get_token(image, client)
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"{self.registry_url}/v2/{image.full_name}/tags/list"
resp = await client.get(url, headers=headers)
if resp.status_code != HTTP_OK:
return []
tags: list[str] = resp.json().get("tags", [])
return tags
async def get_digest(self, image: ImageRef, tag: str, client: httpx.AsyncClient) -> str | None:
"""Get digest for a specific tag."""
headers = {"Accept": MANIFEST_ACCEPT}
token = await self._get_token(image, client)
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"{self.registry_url}/v2/{image.full_name}/manifests/{tag}"
resp = await client.head(url, headers=headers)
if resp.status_code == HTTP_OK:
digest: str | None = resp.headers.get("docker-content-digest")
return digest
return None
def _parse_version(tag: str) -> tuple[int, ...] | None:
"""Parse version string into comparable tuple."""
tag = tag.lstrip("vV")
parts = tag.split(".")
try:
return tuple(int(p) for p in parts)
except ValueError:
return None
def _find_updates(current_tag: str, tags: list[str]) -> list[str]:
"""Find tags newer than current based on version comparison."""
current_version = _parse_version(current_tag)
if current_version is None:
return []
updates = []
for tag in tags:
tag_version = _parse_version(tag)
if tag_version and tag_version > current_version:
updates.append(tag)
updates.sort(key=lambda t: _parse_version(t) or (), reverse=True)
return updates
async def check_image_updates(
image_str: str,
client: httpx.AsyncClient,
) -> TagCheckResult:
"""Check if newer versions are available for an image.
Args:
image_str: Image string like "nginx:1.25" or "ghcr.io/user/repo:tag"
client: httpx async client
Returns:
TagCheckResult with available updates
"""
image = ImageRef.parse(image_str)
registry_client = RegistryClient(image.registry)
try:
tags = await registry_client.get_tags(image, client)
updates = _find_updates(image.tag, tags)
current_digest = await registry_client.get_digest(image, image.tag, client) or ""
return TagCheckResult(
image=image,
current_digest=current_digest,
available_updates=updates,
)
except Exception as e:
return TagCheckResult(
image=image,
current_digest="",
error=str(e),
)

View File

@@ -6,15 +6,16 @@ import asyncio
import logging
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
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.routes import actions, api, containers, pages
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
# Configure logging with Rich handler for compose_farm.web modules
@@ -64,10 +65,14 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
# Enable Gzip compression for faster transfers over slow networks
app.add_middleware(cast("Any", GZipMiddleware), minimum_size=1000)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(pages.router)
app.include_router(containers.router)
app.include_router(api.router, prefix="/api")
app.include_router(actions.router, prefix="/api")

View File

@@ -39,6 +39,14 @@ CDN_ASSETS: dict[str, tuple[str, str]] = {
"xterm-fit.js",
"application/javascript",
),
"https://unpkg.com/idiomorph/dist/idiomorph.min.js": (
"idiomorph.js",
"application/javascript",
),
"https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js": (
"idiomorph-ext.js",
"application/javascript",
),
# Monaco editor - dynamically loaded by app.js
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": (
"monaco-loader.js",

View File

@@ -38,7 +38,17 @@ def get_templates() -> Jinja2Templates:
def extract_config_error(exc: Exception) -> str:
"""Extract a user-friendly error message from a config exception."""
if isinstance(exc, ValidationError):
return "; ".join(err.get("msg", str(err)) for err in exc.errors())
parts = []
for err in exc.errors():
msg = err.get("msg", str(err))
loc = err.get("loc", ())
if loc:
# Format location as dot-separated path (e.g., "hosts.nas.port")
loc_str = ".".join(str(part) for part in loc)
parts.append(f"{loc_str}: {msg}")
else:
parts.append(msg)
return "; ".join(parts)
return str(exc)

View File

@@ -1,5 +1,5 @@
"""Web routes."""
from compose_farm.web.routes import actions, api, pages
from compose_farm.web.routes import actions, api, containers, pages
__all__ = ["actions", "api", "pages"]
__all__ = ["actions", "api", "containers", "pages"]

View File

@@ -21,7 +21,8 @@ from fastapi.responses import HTMLResponse
from compose_farm.compose import get_container_name
from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs
from compose_farm.paths import find_config_path
from compose_farm.glances import fetch_all_host_stats
from compose_farm.paths import backup_dir, find_config_path
from compose_farm.state import load_state
from compose_farm.web.deps import get_config, get_templates
@@ -41,26 +42,30 @@ def _validate_yaml(content: str) -> None:
def _backup_file(file_path: Path) -> Path | None:
"""Create a timestamped backup of a file if it exists and content differs.
Backups are stored in a .backups directory alongside the file.
Backups are stored in XDG config dir under compose-farm/backups/.
The original file's absolute path is mirrored in the backup directory.
Returns the backup path if created, None if no backup was needed.
"""
if not file_path.exists():
return None
# Create backup directory
backup_dir = file_path.parent / ".backups"
backup_dir.mkdir(exist_ok=True)
# Create backup directory mirroring original path structure
# e.g., /opt/stacks/plex/compose.yaml -> ~/.config/compose-farm/backups/opt/stacks/plex/
# On Windows: C:\Users\foo\stacks -> backups/Users/foo/stacks
resolved = file_path.resolve()
file_backup_dir = backup_dir() / resolved.parent.relative_to(resolved.anchor)
file_backup_dir.mkdir(parents=True, exist_ok=True)
# Generate timestamped backup filename
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
backup_name = f"{file_path.name}.{timestamp}"
backup_path = backup_dir / backup_name
backup_path = file_backup_dir / backup_name
# Copy current content to backup
backup_path.write_text(file_path.read_text())
# Clean up old backups (keep last 200)
backups = sorted(backup_dir.glob(f"{file_path.name}.*"), reverse=True)
backups = sorted(file_backup_dir.glob(f"{file_path.name}.*"), reverse=True)
for old_backup in backups[200:]:
old_backup.unlink()
@@ -381,3 +386,19 @@ async def write_console_file(
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
@router.get("/glances", response_class=HTMLResponse)
async def get_glances_stats() -> HTMLResponse:
"""Get resource stats from Glances for all hosts."""
config = get_config()
if not config.glances_stack:
return HTMLResponse("") # Glances not configured
stats = await fetch_all_host_stats(config)
templates = get_templates()
template = templates.env.get_template("partials/glances.html")
html = template.render(stats=stats)
return HTMLResponse(html)

View File

@@ -0,0 +1,370 @@
"""Container dashboard routes using Glances API."""
from __future__ import annotations
import html
import re
from typing import TYPE_CHECKING
from urllib.parse import quote
import humanize
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from compose_farm.executor import TTLCache
from compose_farm.glances import ContainerStats, fetch_all_container_stats
from compose_farm.registry import DOCKER_HUB_ALIASES, ImageRef
from compose_farm.web.deps import get_config, get_templates
router = APIRouter(tags=["containers"])
if TYPE_CHECKING:
from compose_farm.registry import TagCheckResult
# Cache registry update checks for 5 minutes (300 seconds)
# Registry calls are slow and often rate-limited
_update_check_cache = TTLCache(ttl_seconds=300.0)
# Minimum parts needed to infer stack/service from container name
MIN_NAME_PARTS = 2
# HTML for "no update info" dash
_DASH_HTML = '<span class="text-xs opacity-50">-</span>'
def _format_bytes(bytes_val: int) -> str:
"""Format bytes to human readable string."""
return humanize.naturalsize(bytes_val, binary=True, format="%.1f")
def _parse_image(image: str) -> tuple[str, str]:
"""Parse image string into (name, tag)."""
# Handle registry prefix (e.g., ghcr.io/user/repo:tag)
if ":" in image:
# Find last colon that's not part of port
parts = image.rsplit(":", 1)
if "/" in parts[-1]:
# The "tag" contains a slash, so it's probably a port
return image, "latest"
return parts[0], parts[1]
return image, "latest"
def _infer_stack_service(name: str) -> tuple[str, str]:
"""Fallback: infer stack and service from container name.
Used when compose labels are not available.
Docker Compose naming conventions:
- Default: {project}_{service}_{instance} or {project}-{service}-{instance}
- Custom: {container_name} from compose file
"""
# Try underscore separator first (older compose)
if "_" in name:
parts = name.split("_")
if len(parts) >= MIN_NAME_PARTS:
return parts[0], parts[1]
# Try hyphen separator (newer compose)
if "-" in name:
parts = name.split("-")
if len(parts) >= MIN_NAME_PARTS:
return parts[0], "-".join(parts[1:-1]) if len(parts) > MIN_NAME_PARTS else parts[1]
# Fallback: use name as both stack and service
return name, name
@router.get("/live-stats", response_class=HTMLResponse)
async def containers_page(request: Request) -> HTMLResponse:
"""Container dashboard page."""
config = get_config()
templates = get_templates()
# Check if Glances is configured
glances_enabled = config.glances_stack is not None
return templates.TemplateResponse(
"containers.html",
{
"request": request,
"glances_enabled": glances_enabled,
"hosts": sorted(config.hosts.keys()) if glances_enabled else [],
},
)
_STATUS_CLASSES = {
"running": "badge badge-success badge-sm",
"exited": "badge badge-error badge-sm",
"paused": "badge badge-warning badge-sm",
}
def _status_class(status: str) -> str:
"""Get CSS class for status badge."""
return _STATUS_CLASSES.get(status.lower(), "badge badge-ghost badge-sm")
def _progress_class(percent: float) -> str:
"""Get CSS class for progress bar color."""
if percent > 80: # noqa: PLR2004
return "bg-error"
if percent > 50: # noqa: PLR2004
return "bg-warning"
return "bg-success"
def _render_update_cell(image: str, tag: str) -> str:
"""Render update check cell with client-side batch updates."""
encoded_image = quote(image, safe="")
encoded_tag = quote(tag, safe="")
cached_html = _update_check_cache.get(f"{image}:{tag}")
inner = cached_html if cached_html is not None else _DASH_HTML
return (
f"""<td class="update-cell" data-image="{encoded_image}" data-tag="{encoded_tag}">"""
f"{inner}</td>"
)
def _image_web_url(image: str) -> str | None:
"""Return a human-friendly registry URL for an image (without tag)."""
ref = ImageRef.parse(image)
if ref.registry in DOCKER_HUB_ALIASES:
if ref.namespace == "library":
return f"https://hub.docker.com/_/{ref.name}"
return f"https://hub.docker.com/r/{ref.namespace}/{ref.name}"
return f"https://{ref.registry}/{ref.full_name}"
def _render_row(c: ContainerStats, idx: int | str) -> str:
"""Render a single container as an HTML table row."""
image_name, tag = _parse_image(c.image)
stack = c.stack if c.stack else _infer_stack_service(c.name)[0]
service = c.service if c.service else _infer_stack_service(c.name)[1]
cpu = c.cpu_percent
mem = c.memory_percent
cpu_class = _progress_class(cpu)
mem_class = _progress_class(mem)
# Highlight rows with high resource usage
high_cpu = cpu > 80 # noqa: PLR2004
high_mem = mem > 90 # noqa: PLR2004
row_class = "high-usage" if (high_cpu or high_mem) else ""
uptime_sec = _parse_uptime_seconds(c.uptime)
actions = _render_actions(stack)
update_cell = _render_update_cell(image_name, tag)
image_label = f"{image_name}:{tag}"
image_url = _image_web_url(image_name)
if image_url:
image_html = (
f'<a href="{image_url}" target="_blank" rel="noopener noreferrer" '
f'class="link link-hover">'
f'<code class="text-xs bg-base-200 px-1 rounded">{image_label}</code></a>'
)
else:
image_html = f'<code class="text-xs bg-base-200 px-1 rounded">{image_label}</code>'
# Render as single line to avoid whitespace nodes in DOM
row_id = f"c-{c.host}-{c.name}"
class_attr = f' class="{row_class}"' if row_class else ""
return (
f'<tr id="{row_id}" data-host="{c.host}"{class_attr}><td class="text-xs opacity-50">{idx}</td>'
f'<td data-sort="{stack.lower()}"><a href="/stack/{stack}" class="link link-hover link-primary" hx-boost="true">{stack}</a></td>'
f'<td data-sort="{service.lower()}" class="text-xs opacity-70">{service}</td>'
f"<td>{actions}</td>"
f'<td data-sort="{c.host.lower()}"><span class="badge badge-outline badge-xs">{c.host}</span></td>'
f'<td data-sort="{c.image.lower()}">{image_html}</td>'
f"{update_cell}"
f'<td data-sort="{c.status.lower()}"><span class="{_status_class(c.status)}">{c.status}</span></td>'
f'<td data-sort="{uptime_sec}" class="text-xs text-right font-mono">{c.uptime or "-"}</td>'
f'<td data-sort="{cpu}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {cpu_class}" style="width: {min(cpu, 100)}%"></div></div><span class="text-xs">{cpu:.0f}%</span></div></td>'
f'<td data-sort="{c.memory_usage}" class="text-right font-mono"><div class="flex flex-col items-end gap-0.5"><div class="w-12 h-2 bg-base-300 rounded-full overflow-hidden"><div class="h-full {mem_class}" style="width: {min(mem, 100)}%"></div></div><span class="text-xs">{_format_bytes(c.memory_usage)}</span></div></td>'
f'<td data-sort="{c.network_rx + c.network_tx}" class="text-xs text-right font-mono">↓{_format_bytes(c.network_rx)}{_format_bytes(c.network_tx)}</td>'
"</tr>"
)
def _render_actions(stack: str) -> str:
"""Render actions dropdown for a container row."""
return f"""<button class="btn btn-circle btn-ghost btn-xs" onclick="openActionMenu(event, '{stack}')" aria-label="Actions for {stack}">
<svg class="h-4 w-4"><use href="#icon-menu" /></svg>
</button>"""
def _parse_uptime_seconds(uptime: str) -> int:
"""Parse uptime string to seconds for sorting."""
if not uptime:
return 0
uptime = uptime.lower().strip()
# Handle "a/an" as 1
uptime = uptime.replace("an ", "1 ").replace("a ", "1 ")
total = 0
multipliers = {
"second": 1,
"minute": 60,
"hour": 3600,
"day": 86400,
"week": 604800,
"month": 2592000,
"year": 31536000,
}
for match in re.finditer(r"(\d+)\s*(\w+)", uptime):
num = int(match.group(1))
unit = match.group(2).rstrip("s") # Remove plural 's'
total += num * multipliers.get(unit, 0)
return total
@router.get("/api/containers/rows", response_class=HTMLResponse)
async def get_containers_rows() -> HTMLResponse:
"""Get container table rows as HTML for HTMX.
Each cell has data-sort attribute for instant client-side sorting.
"""
config = get_config()
if not config.glances_stack:
return HTMLResponse(
'<tr><td colspan="12" class="text-center text-error">Glances not configured</td></tr>'
)
containers = await fetch_all_container_stats(config)
if not containers:
return HTMLResponse(
'<tr><td colspan="12" class="text-center py-4 opacity-60">No containers found</td></tr>'
)
rows = "\n".join(_render_row(c, i + 1) for i, c in enumerate(containers))
return HTMLResponse(rows)
@router.get("/api/containers/rows/{host_name}", response_class=HTMLResponse)
async def get_containers_rows_by_host(host_name: str) -> HTMLResponse:
"""Get container rows for a specific host.
Returns immediately with Glances data. Stack/service are inferred from
container names for instant display (no SSH wait).
"""
import logging # noqa: PLC0415
import time # noqa: PLC0415
from compose_farm.executor import get_container_compose_labels # noqa: PLC0415
from compose_farm.glances import fetch_container_stats # noqa: PLC0415
logger = logging.getLogger(__name__)
config = get_config()
if host_name not in config.hosts:
return HTMLResponse("")
host = config.hosts[host_name]
t0 = time.monotonic()
containers, error = await fetch_container_stats(host_name, host.address)
t1 = time.monotonic()
fetch_ms = (t1 - t0) * 1000
if containers is None:
logger.error(
"Failed to fetch stats for %s in %.1fms: %s",
host_name,
fetch_ms,
error,
)
return HTMLResponse(
f'<tr class="text-error"><td colspan="12" class="text-center py-2">Error: {error}</td></tr>'
)
if not containers:
return HTMLResponse("") # No rows for this host
labels = await get_container_compose_labels(config, host_name)
for c in containers:
stack, service = labels.get(c.name, ("", ""))
if not stack or not service:
stack, service = _infer_stack_service(c.name)
c.stack, c.service = stack, service
# Only show containers from stacks in config (filters out orphaned/unknown stacks)
containers = [c for c in containers if not c.stack or c.stack in config.stacks]
# Use placeholder index (will be renumbered by JS after all hosts load)
rows = "\n".join(_render_row(c, "-") for c in containers)
t2 = time.monotonic()
render_ms = (t2 - t1) * 1000
logger.info(
"Loaded %d rows for %s in %.1fms (fetch) + %.1fms (render)",
len(containers),
host_name,
fetch_ms,
render_ms,
)
return HTMLResponse(rows)
def _render_update_badge(result: TagCheckResult) -> str:
if result.error:
return _DASH_HTML
if result.available_updates:
updates = result.available_updates
count = len(updates)
title = f"Newer: {', '.join(updates[:3])}" + ("..." if count > 3 else "") # noqa: PLR2004
tip = html.escape(title, quote=True)
return (
f'<span class="tooltip" data-tip="{tip}">'
f'<span class="badge badge-warning badge-xs cursor-help">{count} new</span>'
"</span>"
)
return '<span class="tooltip" data-tip="Up to date"><span class="text-success text-xs">✓</span></span>'
@router.post("/api/containers/check-updates", response_class=JSONResponse)
async def check_container_updates_batch(request: Request) -> JSONResponse:
"""Batch update checks for a list of images.
Payload: {"items": [{"image": "...", "tag": "..."}, ...]}
Returns: {"results": [{"image": "...", "tag": "...", "html": "..."}, ...]}
"""
import httpx # noqa: PLC0415
payload = await request.json()
items = payload.get("items", []) if isinstance(payload, dict) else []
if not items:
return JSONResponse({"results": []})
results = []
from compose_farm.registry import check_image_updates # noqa: PLC0415
async with httpx.AsyncClient(timeout=10.0) as client:
for item in items:
image = item.get("image", "")
tag = item.get("tag", "")
full_image = f"{image}:{tag}"
if not image or not tag:
results.append({"image": image, "tag": tag, "html": _DASH_HTML})
continue
# NOTE: Tag-based checks cannot detect digest changes for moving tags
# like "latest". A future improvement could compare remote vs local
# digests using dockerfarm-log.toml (from `cf refresh`) or a per-host
# digest lookup.
cached_html: str | None = _update_check_cache.get(full_image)
if cached_html is not None:
results.append({"image": image, "tag": tag, "html": cached_html})
continue
try:
result = await check_image_updates(full_image, client)
html = _render_update_badge(result)
_update_check_cache.set(full_image, html)
except Exception:
_update_check_cache.set(full_image, _DASH_HTML, ttl_seconds=60.0)
html = _DASH_HTML
results.append({"image": image, "tag": tag, "html": html})
return JSONResponse({"results": results})

View File

@@ -91,8 +91,8 @@ async def index(request: Request) -> HTMLResponse:
# Get state
deployed = load_state(config)
# Stats
running_count = len(deployed)
# Stats (only count stacks that are both in config AND deployed)
running_count = sum(1 for stack in deployed if stack in config.stacks)
stopped_count = len(config.stacks) - running_count
# Pending operations
@@ -250,7 +250,8 @@ async def stats_partial(request: Request) -> HTMLResponse:
templates = get_templates()
deployed = load_state(config)
running_count = len(deployed)
# Only count stacks that are both in config AND deployed
running_count = sum(1 for stack in deployed if stack in config.stacks)
stopped_count = len(config.stacks) - running_count
return templates.TemplateResponse(

View File

@@ -9,7 +9,6 @@
// ANSI escape codes for terminal output
const ANSI = {
RED: '\x1b[31m',
GREEN: '\x1b[32m',
DIM: '\x1b[2m',
RESET: '\x1b[0m',
CRLF: '\r\n'
@@ -122,7 +121,6 @@ function whenXtermReady(callback, maxAttempts = 20) {
};
tryInit(maxAttempts);
}
window.whenXtermReady = whenXtermReady;
// ============================================================================
// TERMINAL
@@ -209,8 +207,6 @@ function initTerminal(elementId, taskId) {
return { term, ws };
}
window.initTerminal = initTerminal;
/**
* Initialize an interactive exec terminal
*/
@@ -223,7 +219,9 @@ function initExecTerminal(stack, container, host) {
return;
}
// Unhide the terminal container first, then expand/scroll
containerEl.classList.remove('hidden');
expandCollapse(document.getElementById('exec-collapse'), containerEl);
// Clean up existing (use wrapper's dispose to clean up ResizeObserver)
if (execWs) { execWs.close(); execWs = null; }
@@ -259,17 +257,42 @@ function initExecTerminal(stack, container, host) {
window.initExecTerminal = initExecTerminal;
/**
* Expand a collapse component and scroll to a target element
* @param {HTMLInputElement} toggle - The checkbox input that controls the collapse
* @param {HTMLElement} [scrollTarget] - Element to scroll to (defaults to collapse container)
*/
function expandCollapse(toggle, scrollTarget = null) {
if (!toggle) return;
// Find the parent collapse container
const collapse = toggle.closest('.collapse');
if (!collapse) return;
const target = scrollTarget || collapse;
const scrollToTarget = () => {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
if (!toggle.checked) {
// Collapsed - expand first, then scroll after transition
const onTransitionEnd = () => {
collapse.removeEventListener('transitionend', onTransitionEnd);
scrollToTarget();
};
collapse.addEventListener('transitionend', onTransitionEnd);
toggle.checked = true;
} else {
// Already expanded - just scroll
scrollToTarget();
}
}
/**
* Expand terminal collapse and scroll to it
*/
function expandTerminal() {
const toggle = document.getElementById('terminal-toggle');
if (toggle) toggle.checked = true;
const collapse = document.getElementById('terminal-collapse');
if (collapse) {
collapse.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
expandCollapse(document.getElementById('terminal-toggle'));
}
/**
@@ -405,7 +428,7 @@ function initMonacoEditors() {
* Save all editors
*/
async function saveAllEditors() {
const saveBtn = document.getElementById('save-btn') || document.getElementById('save-config-btn');
const saveBtn = getSaveButton();
const results = [];
for (const [id, editor] of Object.entries(editors)) {
@@ -441,12 +464,16 @@ async function saveAllEditors() {
* Initialize save button handler
*/
function initSaveButton() {
const saveBtn = document.getElementById('save-btn') || document.getElementById('save-config-btn');
const saveBtn = getSaveButton();
if (!saveBtn) return;
saveBtn.onclick = saveAllEditors;
}
function getSaveButton() {
return document.getElementById('save-btn') || document.getElementById('save-config-btn');
}
// ============================================================================
// UI HELPERS
// ============================================================================
@@ -531,6 +558,7 @@ function playFabIntro() {
}
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
history.pushState({}, '', url);
window.scrollTo(0, 0);
afterNav?.();
});
};
@@ -539,6 +567,7 @@ function playFabIntro() {
if (window.location.pathname !== '/') {
await htmx.ajax('GET', '/', {target: '#main-content', select: '#main-content', swap: 'outerHTML'});
history.pushState({}, '', '/');
window.scrollTo(0, 0);
}
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
};
@@ -578,9 +607,11 @@ function playFabIntro() {
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('app', 'Live Stats', 'View all containers across hosts', nav('/live-stats'), icons.box),
cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code),
cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open),
cmd('app', 'GitHub Repo', 'Open GitHub repository', openExternal('https://github.com/basnijholt/compose-farm'), icons.external_link),
];
// Add stack-specific actions if on a stack page
@@ -713,11 +744,6 @@ function playFabIntro() {
input.focus();
}
function close() {
dialog.close();
restoreTheme();
}
function exec() {
const cmd = filtered[selected];
if (cmd) {
@@ -839,6 +865,119 @@ function initPage() {
initMonacoEditors();
initSaveButton();
updateShortcutKeys();
initLiveStats();
initSharedActionMenu();
maybeRunStackAction();
}
function navigateToStack(stack, action = null) {
const url = action ? `/stack/${stack}?action=${action}` : `/stack/${stack}`;
window.location.href = url;
}
/**
* Initialize shared action menu for container rows
*/
function initSharedActionMenu() {
const menuEl = document.getElementById('shared-action-menu');
if (!menuEl) return;
if (menuEl.dataset.bound === '1') return;
menuEl.dataset.bound = '1';
let hoverTimeout = null;
function showMenuForButton(btn, stack) {
menuEl.dataset.stack = stack;
// Position menu relative to button
const rect = btn.getBoundingClientRect();
menuEl.classList.remove('hidden');
menuEl.style.visibility = 'hidden';
const menuRect = menuEl.getBoundingClientRect();
const left = rect.right - menuRect.width + window.scrollX;
const top = rect.bottom + window.scrollY;
menuEl.style.top = `${top}px`;
menuEl.style.left = `${left}px`;
menuEl.style.visibility = '';
if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = true;
}
function closeMenu() {
menuEl.classList.add('hidden');
if (typeof liveStats !== 'undefined') liveStats.dropdownOpen = false;
menuEl.dataset.stack = '';
}
function scheduleClose() {
if (hoverTimeout) clearTimeout(hoverTimeout);
hoverTimeout = setTimeout(closeMenu, 100);
}
function cancelClose() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
}
// Button hover: show menu (event delegation on tbody)
const tbody = document.getElementById('container-rows');
if (tbody) {
tbody.addEventListener('mouseenter', (e) => {
const btn = e.target.closest('button[onclick^="openActionMenu"]');
if (!btn) return;
// Extract stack from onclick attribute
const match = btn.getAttribute('onclick')?.match(/openActionMenu\(event,\s*'([^']+)'\)/);
if (!match) return;
cancelClose();
showMenuForButton(btn, match[1]);
}, true);
tbody.addEventListener('mouseleave', (e) => {
const btn = e.target.closest('button[onclick^="openActionMenu"]');
if (btn) scheduleClose();
}, true);
}
// Keep menu open while hovering over it
menuEl.addEventListener('mouseenter', cancelClose);
menuEl.addEventListener('mouseleave', scheduleClose);
// Click action in menu
menuEl.addEventListener('click', (e) => {
const link = e.target.closest('a[data-action]');
const stack = menuEl.dataset.stack;
if (!link || !stack) return;
e.preventDefault();
navigateToStack(stack, link.dataset.action);
closeMenu();
});
// Also support click on button (for touch/accessibility)
window.openActionMenu = function(event, stack) {
event.stopPropagation();
showMenuForButton(event.currentTarget, stack);
};
// Close on outside click
document.body.addEventListener('click', (e) => {
if (!menuEl.classList.contains('hidden') &&
!menuEl.contains(e.target) &&
!e.target.closest('button[onclick^="openActionMenu"]')) {
closeMenu();
}
});
// Close on Escape
document.body.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMenu();
});
}
/**
@@ -859,6 +998,30 @@ function tryReconnectToTask(path) {
});
}
function maybeRunStackAction() {
const params = new URLSearchParams(window.location.search);
const stackEl = document.querySelector('[data-stack-name]');
const stackName = stackEl?.dataset?.stackName;
if (!stackName) return;
const action = params.get('action');
if (!action) return;
const button = document.querySelector(`button[hx-post="/api/stack/${stackName}/${action}"]`);
if (!button) return;
params.delete('action');
const newQuery = params.toString();
const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname;
history.replaceState({}, '', newUrl);
if (window.htmx) {
htmx.trigger(button, 'click');
} else {
button.click();
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initPage();
@@ -900,3 +1063,443 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
// Not valid JSON, ignore
}
});
// ============================================================================
// LIVE STATS PAGE
// ============================================================================
// State persists across SPA navigation (intervals must be cleared on re-init)
let liveStats = {
sortCol: 9,
sortAsc: false,
lastUpdate: 0,
dropdownOpen: false,
scrolling: false,
scrollTimer: null,
loadingHosts: new Set(),
eventsBound: false,
intervals: [],
updateCheckTimes: new Map(),
autoRefresh: true
};
const REFRESH_INTERVAL = 5000;
const UPDATE_CHECK_TTL = 120000;
const NUMERIC_COLS = new Set([8, 9, 10, 11]); // uptime, cpu, mem, net
function filterTable() {
const textFilter = document.getElementById('filter-input')?.value.toLowerCase() || '';
const hostFilter = document.getElementById('host-filter')?.value || '';
const rows = document.querySelectorAll('#container-rows tr');
let visible = 0;
let total = 0;
rows.forEach(row => {
// Skip loading/empty/error rows (they have colspan)
if (row.cells[0]?.colSpan > 1) return;
total++;
const matchesText = !textFilter || row.textContent.toLowerCase().includes(textFilter);
const matchesHost = !hostFilter || row.dataset.host === hostFilter;
const show = matchesText && matchesHost;
row.style.display = show ? '' : 'none';
if (show) visible++;
});
const countEl = document.getElementById('container-count');
if (countEl) {
const isFiltering = textFilter || hostFilter;
countEl.textContent = total > 0
? (isFiltering ? `${visible} of ${total} containers` : `${total} containers`)
: '';
}
}
window.filterTable = filterTable;
function sortTable(col) {
if (liveStats.sortCol === col) {
liveStats.sortAsc = !liveStats.sortAsc;
} else {
liveStats.sortCol = col;
liveStats.sortAsc = false;
}
updateSortIndicators();
doSort();
}
window.sortTable = sortTable;
function updateSortIndicators() {
document.querySelectorAll('thead th').forEach((th, i) => {
const span = th.querySelector('.sort-indicator');
if (span) {
span.textContent = (i === liveStats.sortCol) ? (liveStats.sortAsc ? '↑' : '↓') : '';
span.style.opacity = (i === liveStats.sortCol) ? '1' : '0.3';
}
});
}
function doSort() {
const tbody = document.getElementById('container-rows');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length === 0) return;
if (rows.length === 1 && rows[0].cells[0]?.colSpan > 1) return; // Empty state row
const isNumeric = NUMERIC_COLS.has(liveStats.sortCol);
rows.sort((a, b) => {
// Pin placeholders/empty rows to the bottom
const aLoading = a.classList.contains('loading-row') || a.classList.contains('host-empty') || a.cells[0]?.colSpan > 1;
const bLoading = b.classList.contains('loading-row') || b.classList.contains('host-empty') || b.cells[0]?.colSpan > 1;
if (aLoading && !bLoading) return 1;
if (!aLoading && bLoading) return -1;
if (aLoading && bLoading) return 0;
const aVal = a.cells[liveStats.sortCol]?.dataset?.sort ?? '';
const bVal = b.cells[liveStats.sortCol]?.dataset?.sort ?? '';
const cmp = isNumeric ? aVal - bVal : aVal.localeCompare(bVal);
return liveStats.sortAsc ? cmp : -cmp;
});
let index = 1;
const fragment = document.createDocumentFragment();
rows.forEach((row) => {
if (row.cells.length > 1) {
row.cells[0].textContent = index++;
}
fragment.appendChild(row);
});
tbody.appendChild(fragment);
}
function isLoading() {
return liveStats.loadingHosts.size > 0;
}
function getLiveStatsHosts() {
const tbody = document.getElementById('container-rows');
if (!tbody) return [];
const dataHosts = tbody.dataset.hosts || '';
return dataHosts.split(',').map(h => h.trim()).filter(Boolean);
}
function buildHostRow(host, message, className) {
return (
`<tr class="${className}" data-host="${host}">` +
`<td colspan="12" class="text-center py-2">` +
`<span class="text-sm opacity-60">${message}</span>` +
`</td></tr>`
);
}
async function checkUpdatesForHost(host) {
// Update checks always run - they only update small cells, not disruptive
const last = liveStats.updateCheckTimes.get(host) || 0;
if (Date.now() - last < UPDATE_CHECK_TTL) return;
const cells = Array.from(
document.querySelectorAll(`tr[data-host="${host}"] td.update-cell[data-image][data-tag]`)
);
if (cells.length === 0) return;
const items = [];
const seen = new Set();
cells.forEach(cell => {
const image = decodeURIComponent(cell.dataset.image || '');
const tag = decodeURIComponent(cell.dataset.tag || '');
const key = `${image}:${tag}`;
if (!image || seen.has(key)) return;
seen.add(key);
items.push({ image, tag });
});
if (items.length === 0) return;
try {
const response = await fetch('/api/containers/check-updates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
if (!response.ok) return;
const data = await response.json();
const results = Array.isArray(data?.results) ? data.results : [];
const htmlMap = new Map();
results.forEach(result => {
const key = `${result.image}:${result.tag}`;
htmlMap.set(key, result.html);
});
cells.forEach(cell => {
const image = decodeURIComponent(cell.dataset.image || '');
const tag = decodeURIComponent(cell.dataset.tag || '');
const key = `${image}:${tag}`;
const html = htmlMap.get(key);
if (html && cell.innerHTML !== html) {
cell.innerHTML = html;
}
});
liveStats.updateCheckTimes.set(host, Date.now());
} catch (e) {
console.error('Update check failed:', e);
}
}
function replaceHostRows(host, html) {
const tbody = document.getElementById('container-rows');
if (!tbody) return;
// Remove loading indicator for this host if present
const loadingRow = tbody.querySelector(`tr.loading-row[data-host="${host}"]`);
if (loadingRow) loadingRow.remove();
const template = document.createElement('template');
template.innerHTML = html.trim();
let newRows = Array.from(template.content.children).filter(el => el.tagName === 'TR');
if (newRows.length === 0) {
// Only show empty message if we don't have any rows for this host
const existing = tbody.querySelector(`tr[data-host="${host}"]:not(.loading-row)`);
if (!existing) {
template.innerHTML = buildHostRow(host, `No containers on ${host}`, 'host-empty');
newRows = Array.from(template.content.children);
}
}
// Track which IDs we've seen in this update
const newIds = new Set();
newRows.forEach(newRow => {
const id = newRow.id;
if (id) newIds.add(id);
if (id) {
const existing = document.getElementById(id);
if (existing) {
// Morph in place if Idiomorph is available, otherwise replace
if (typeof Idiomorph !== 'undefined') {
Idiomorph.morph(existing, newRow);
} else {
existing.replaceWith(newRow);
}
// Re-process HTMX if needed (though inner content usually carries attributes)
const morphedRow = document.getElementById(id);
if (window.htmx) htmx.process(morphedRow);
// Trigger refresh animation
if (morphedRow) {
morphedRow.classList.add('row-updated');
setTimeout(() => morphedRow.classList.remove('row-updated'), 500);
}
} else {
// New row - append (will be sorted later)
tbody.appendChild(newRow);
if (window.htmx) htmx.process(newRow);
// Animate new rows too
newRow.classList.add('row-updated');
setTimeout(() => newRow.classList.remove('row-updated'), 500);
}
} else {
// Fallback for rows without ID (like error/empty messages)
// Just append them, cleaning up previous generic rows handled below
tbody.appendChild(newRow);
}
});
// Remove orphaned rows for this host (rows that exist in DOM but not in new response)
// Be careful not to remove rows that were just added (if they lack IDs)
const currentHostRows = Array.from(tbody.querySelectorAll(`tr[data-host="${host}"]`));
currentHostRows.forEach(row => {
// Skip if it's one of the new rows we just appended (check presence in newRows?)
// Actually, if we just appended it, it is in DOM.
// We rely on ID matching.
// Error/Empty rows usually don't have ID, but we handle them by clearing old ones?
// Let's assume data rows have IDs.
if (row.id && !newIds.has(row.id)) {
row.remove();
}
// Also remove old empty/error messages if we now have data
if (!row.id && newRows.length > 0 && newRows[0].id) {
row.remove();
}
});
liveStats.loadingHosts.delete(host);
checkUpdatesForHost(host);
scheduleRowUpdate();
}
async function loadHostRows(host) {
liveStats.loadingHosts.add(host);
try {
const response = await fetch(`/api/containers/rows/${encodeURIComponent(host)}`);
const html = response.ok ? await response.text() : '';
replaceHostRows(host, html);
} catch (e) {
console.error(`Failed to load ${host}:`, e);
const msg = e.message || String(e);
// Fallback to simpler error display if replaceHostRows fails (e.g. Idiomorph missing)
try {
replaceHostRows(host, buildHostRow(host, `Error: ${msg}`, 'text-error'));
} catch (err2) {
// Last resort: find row and force innerHTML
const tbody = document.getElementById('container-rows');
const row = tbody?.querySelector(`tr[data-host="${host}"]`);
if (row) row.innerHTML = `<td colspan="12" class="text-center text-error">Error: ${msg}</td>`;
}
} finally {
liveStats.loadingHosts.delete(host);
}
}
function refreshLiveStats() {
if (liveStats.dropdownOpen || liveStats.scrolling) return;
const hosts = getLiveStatsHosts();
if (hosts.length === 0) return;
liveStats.lastUpdate = Date.now();
hosts.forEach(loadHostRows);
}
window.refreshLiveStats = refreshLiveStats;
function toggleAutoRefresh() {
liveStats.autoRefresh = !liveStats.autoRefresh;
const timer = document.getElementById('refresh-timer');
if (timer) {
timer.classList.toggle('btn-error', !liveStats.autoRefresh);
timer.classList.toggle('btn-outline', liveStats.autoRefresh);
}
if (liveStats.autoRefresh) {
// Re-enabling: trigger immediate refresh
refreshLiveStats();
} else {
// Disabling: ensure update checks run for current data
const hosts = getLiveStatsHosts();
hosts.forEach(host => checkUpdatesForHost(host));
}
}
window.toggleAutoRefresh = toggleAutoRefresh;
function initLiveStats() {
if (!document.getElementById('refresh-timer')) return;
// Clear previous intervals (important for SPA navigation)
liveStats.intervals.forEach(clearInterval);
liveStats.intervals = [];
liveStats.lastUpdate = Date.now();
liveStats.dropdownOpen = false;
liveStats.scrolling = false;
if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer);
liveStats.scrollTimer = null;
liveStats.loadingHosts.clear();
liveStats.updateCheckTimes = new Map();
liveStats.autoRefresh = true;
if (!liveStats.eventsBound) {
liveStats.eventsBound = true;
// Dropdown pauses refresh
document.body.addEventListener('click', e => {
liveStats.dropdownOpen = !!e.target.closest('.dropdown');
});
document.body.addEventListener('focusin', e => {
if (e.target.closest('.dropdown')) liveStats.dropdownOpen = true;
});
document.body.addEventListener('focusout', () => {
setTimeout(() => {
liveStats.dropdownOpen = !!document.activeElement?.closest('.dropdown');
}, 150);
});
document.body.addEventListener('keydown', e => {
if (e.key === 'Escape') liveStats.dropdownOpen = false;
});
// Pause refresh while scrolling (helps on slow mobile browsers)
window.addEventListener('scroll', () => {
liveStats.scrolling = true;
if (liveStats.scrollTimer) clearTimeout(liveStats.scrollTimer);
liveStats.scrollTimer = setTimeout(() => {
liveStats.scrolling = false;
}, 200);
}, { passive: true });
}
// Auto-refresh every 5 seconds (skip if disabled, loading, or dropdown open)
liveStats.intervals.push(setInterval(() => {
if (!liveStats.autoRefresh) return;
if (liveStats.dropdownOpen || liveStats.scrolling || isLoading()) return;
refreshLiveStats();
}, REFRESH_INTERVAL));
// Timer display (updates every 100ms)
liveStats.intervals.push(setInterval(() => {
const timer = document.getElementById('refresh-timer');
if (!timer) {
liveStats.intervals.forEach(clearInterval);
return;
}
const loading = isLoading();
const paused = liveStats.dropdownOpen || liveStats.scrolling;
const elapsed = Date.now() - liveStats.lastUpdate;
window.refreshPaused = paused || loading || !liveStats.autoRefresh;
// Update refresh timer button
let text;
if (!liveStats.autoRefresh) {
text = 'OFF';
} else if (paused) {
text = '❚❚';
} else {
const remaining = Math.max(0, REFRESH_INTERVAL - elapsed);
text = loading ? '↻ …' : `${Math.ceil(remaining / 1000)}s`;
}
if (timer.textContent !== text) {
timer.textContent = text;
}
// Update "last updated" display
const lastUpdatedEl = document.getElementById('last-updated');
if (lastUpdatedEl) {
const secs = Math.floor(elapsed / 1000);
const updatedText = secs < 5 ? 'Updated just now' : `Updated ${secs}s ago`;
if (lastUpdatedEl.textContent !== updatedText) {
lastUpdatedEl.textContent = updatedText;
}
}
}, 100));
updateSortIndicators();
refreshLiveStats();
}
function scheduleRowUpdate() {
// Sort and filter immediately to prevent flicker
doSort();
filterTable();
}
// ============================================================================
// STACKS BY HOST FILTER
// ============================================================================
function sbhFilter() {
const query = (document.getElementById('sbh-filter')?.value || '').toLowerCase();
const hostFilter = document.getElementById('sbh-host-select')?.value || '';
document.querySelectorAll('.sbh-group').forEach(group => {
if (hostFilter && group.dataset.h !== hostFilter) {
group.hidden = true;
return;
}
let visibleCount = 0;
group.querySelectorAll('li[data-s]').forEach(li => {
const show = !query || li.dataset.s.includes(query);
li.hidden = !show;
if (show) visibleCount++;
});
group.hidden = visibleCount === 0;
});
}
window.sbhFilter = sbhFilter;

View File

@@ -26,6 +26,23 @@
</script>
</head>
<body class="min-h-screen bg-base-200">
<svg style="display: none">
<symbol id="icon-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="5" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="19" r="1" />
</symbol>
<symbol id="icon-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</symbol>
<symbol id="icon-pull" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</symbol>
<symbol id="icon-update" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</symbol>
<symbol id="icon-logs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</symbol>
</svg>
<div class="drawer lg:drawer-open">
<input id="drawer-toggle" type="checkbox" class="drawer-toggle" />
@@ -80,6 +97,8 @@
<!-- Scripts - HTMX first -->
<script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph.min.js"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js" data-vendor="xterm-fit.js"></script>
<script src="/static/app.js"></script>

View File

@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% from "partials/components.html" import page_header %}
{% from "partials/icons.html" import refresh_cw %}
{% block title %}Live Stats - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-7xl">
{{ page_header("Live Stats", "All running containers across hosts") }}
{% if not glances_enabled %}
<div class="alert alert-warning mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div>
<h3 class="font-bold">Glances not configured</h3>
<div class="text-xs">Add <code class="bg-base-300 px-1 rounded">glances_stack: glances</code> to your config and deploy Glances on all hosts.</div>
</div>
</div>
{% else %}
<!-- Action Bar -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="tooltip" data-tip="Refresh now">
<button class="btn btn-outline btn-sm" type="button" onclick="refreshLiveStats()">
{{ refresh_cw() }} Refresh
</button>
</div>
<div class="tooltip" data-tip="Click to toggle auto-refresh">
<button class="btn btn-outline btn-sm font-mono w-20 justify-center"
id="refresh-timer" onclick="toggleAutoRefresh()"></button>
</div>
<input type="text" id="filter-input" placeholder="Filter containers..."
class="input input-bordered input-sm w-64" onkeyup="filterTable()">
<select id="host-filter" class="select select-bordered select-sm" onchange="filterTable()">
<option value="">All hosts</option>
{% for host in hosts %}<option value="{{ host }}">{{ host }}</option>{% endfor %}
</select>
<span id="container-count" class="text-sm text-base-content/60"></span>
<span id="last-updated" class="text-sm text-base-content/40 ml-auto"></span>
</div>
<!-- Container Table -->
<div class="card bg-base-100 shadow overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead class="sticky top-0 bg-base-200">
<tr>
<th class="w-8">#</th>
<th class="cursor-pointer" onclick="sortTable(1)">Stack<span class="sort-indicator"></span></th>
<th class="cursor-pointer" onclick="sortTable(2)">Service<span class="sort-indicator"></span></th>
<th></th>
<th class="cursor-pointer" onclick="sortTable(4)">Host<span class="sort-indicator"></span></th>
<th class="cursor-pointer" onclick="sortTable(5)">Image<span class="sort-indicator"></span></th>
<th class="w-16">Update</th>
<th class="cursor-pointer" onclick="sortTable(7)">Status<span class="sort-indicator"></span></th>
<th class="cursor-pointer text-right" onclick="sortTable(8)">Uptime<span class="sort-indicator"></span></th>
<th class="cursor-pointer text-right" onclick="sortTable(9)">CPU<span class="sort-indicator"></span></th>
<th class="cursor-pointer text-right" onclick="sortTable(10)">Mem<span class="sort-indicator"></span></th>
<th class="cursor-pointer text-right" onclick="sortTable(11)">Net I/O<span class="sort-indicator"></span></th>
</tr>
</thead>
<tbody id="container-rows" data-hosts="{{ hosts | join(',') }}">
{% for host in hosts %}
<tr class="loading-row" data-host="{{ host }}">
<td colspan="12" class="text-center py-2">
<span class="loading loading-spinner loading-xs"></span>
<span class="text-sm opacity-60">Loading {{ host }}...</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Shared Action Menu -->
<ul id="shared-action-menu" class="menu menu-sm bg-base-200 rounded-box shadow-lg w-36 absolute z-50 p-2 hidden">
<li><a data-action="restart"><svg class="h-4 w-4"><use href="#icon-restart" /></svg>Restart</a></li>
<li><a data-action="pull"><svg class="h-4 w-4"><use href="#icon-pull" /></svg>Pull</a></li>
<li><a data-action="update"><svg class="h-4 w-4"><use href="#icon-update" /></svg>Update</a></li>
<li><a data-action="logs"><svg class="h-4 w-4"><use href="#icon-logs" /></svg>Logs</a></li>
</ul>
</div>
{% endblock %}
{% block scripts %}
{% if glances_enabled %}
<style>
.sort-indicator { display: inline-block; width: 1em; text-align: center; opacity: 0.5; }
.high-usage { background-color: oklch(var(--er) / 0.15) !important; }
/* Refresh animation */
@keyframes row-pulse {
0% { background-color: oklch(var(--p) / 0.2); }
100% { background-color: transparent; }
}
.row-updated { animation: row-pulse 0.5s ease-out; }
</style>
{% endif %}
{% endblock %}

View File

@@ -53,6 +53,13 @@
{% include "partials/stacks_by_host.html" %}
</div>
<!-- Host Resources (Glances) -->
<div id="glances-stats"
hx-get="/api/glances"
hx-trigger="load, cf:refresh from:body, every 30s"
hx-swap="innerHTML">
</div>
<!-- Hosts Configuration -->
{% call collapse("Hosts (" ~ (hosts | length) ~ ")", icon=server()) %}
{% call table() %}

View File

@@ -0,0 +1,66 @@
{# Glances resource stats display #}
{% from "partials/icons.html" import cpu, memory_stick, gauge, server, activity, hard_drive, arrow_down_up, refresh_cw %}
{% macro progress_bar(percent, color="primary") %}
<div class="flex items-center gap-2 min-w-32">
<progress class="progress progress-{{ color }} flex-1" value="{{ percent }}" max="100"></progress>
<span class="text-xs w-10 text-right">{{ "%.1f"|format(percent) }}%</span>
</div>
{% endmacro %}
{% macro format_rate(bytes_per_sec) %}
{%- if bytes_per_sec >= 1048576 -%}
{{ "%.1f"|format(bytes_per_sec / 1048576) }} MB/s
{%- elif bytes_per_sec >= 1024 -%}
{{ "%.1f"|format(bytes_per_sec / 1024) }} KB/s
{%- else -%}
{{ "%.0f"|format(bytes_per_sec) }} B/s
{%- endif -%}
{% endmacro %}
{% macro host_row(host_stats) %}
<tr>
<td class="font-medium">{{ server(14) }} {{ host_stats.host }}</td>
{% if host_stats.error %}
<td colspan="5" class="text-error text-xs">{{ host_stats.error }}</td>
{% else %}
<td>{{ progress_bar(host_stats.cpu_percent, "info") }}</td>
<td>{{ progress_bar(host_stats.mem_percent, "success") }}</td>
<td>{{ progress_bar(host_stats.disk_percent, "warning") }}</td>
<td class="text-xs font-mono">↓{{ format_rate(host_stats.net_rx_rate) }} ↑{{ format_rate(host_stats.net_tx_rate) }}</td>
<td class="text-sm">{{ "%.1f"|format(host_stats.load) }}</td>
{% endif %}
</tr>
{% endmacro %}
<div class="card bg-base-100 shadow mt-4 mb-4">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-base gap-2">{{ activity(18) }} Host Resources</h2>
<button class="btn btn-ghost btn-xs opacity-50 hover:opacity-100"
hx-get="/api/glances" hx-target="#glances-stats" hx-swap="innerHTML"
title="Refresh">
{{ refresh_cw(14) }}
</button>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Host</th>
<th>{{ cpu(14) }} CPU</th>
<th>{{ memory_stick(14) }} Memory</th>
<th>{{ hard_drive(14) }} Disk</th>
<th>{{ arrow_down_up(14) }} Net</th>
<th>{{ gauge(14) }} Load</th>
</tr>
</thead>
<tbody>
{% for host_name, host_stats in stats.items() %}
{{ host_row(host_stats) }}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -176,3 +176,46 @@
<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>
{% endmacro %}
{# Resource monitoring icons #}
{% macro cpu(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">
<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>
</svg>
{% endmacro %}
{% macro memory_stick(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="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837z"/>
</svg>
{% endmacro %}
{% macro gauge(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="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>
</svg>
{% endmacro %}
{% macro activity(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="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/>
</svg>
{% endmacro %}
{% macro arrow_down_up(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 16 4 4 4-4"/><path d="M7 20V4"/><path d="m21 8-4-4-4 4"/><path d="M17 4v16"/>
</svg>
{% endmacro %}
{% macro hard_drive(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">
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
</svg>
{% endmacro %}
{% macro box(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 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
</svg>
{% endmacro %}

View File

@@ -1,8 +1,9 @@
{% from "partials/icons.html" import home, search, terminal %}
{% from "partials/icons.html" import home, search, terminal, box %}
<!-- Navigation Links -->
<div class="mb-4">
<ul class="menu" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
<li><a href="/" class="font-semibold">{{ home() }} Dashboard</a></li>
<li><a href="/live-stats" class="font-semibold">{{ box() }} Live Stats</a></li>
<li><a href="/console" class="font-semibold">{{ terminal() }} Console</a></li>
</ul>
</div>

View File

@@ -20,20 +20,4 @@
{% else %}
<p class="text-base-content/60 italic">No stacks currently running.</p>
{% endfor %}
<script>
function sbhFilter() {
const q = (document.getElementById('sbh-filter')?.value || '').toLowerCase();
const h = document.getElementById('sbh-host-select')?.value || '';
document.querySelectorAll('.sbh-group').forEach(g => {
if (h && g.dataset.h !== h) { g.hidden = true; return; }
let n = 0;
g.querySelectorAll('li[data-s]').forEach(li => {
const show = !q || li.dataset.s.includes(q);
li.hidden = !show;
if (show) n++;
});
g.hidden = !n;
});
}
</script>
{% endcall %}

View File

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

View File

@@ -37,7 +37,7 @@ def _parse_resize(msg: str) -> tuple[int, int] | None:
"""Parse a resize message, return (cols, rows) or None if not a resize."""
try:
data = json.loads(msg)
if data.get("type") == "resize":
if isinstance(data, dict) and data.get("type") == "resize":
return int(data["cols"]), int(data["rows"])
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
pass

View File

@@ -58,8 +58,9 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
):
apply(dry_run=False, no_orphans=False, full=False, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
captured = capsys.readouterr()
assert "Nothing to apply" in captured.out
@@ -82,10 +83,11 @@ class TestApplyCommand:
),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
):
apply(dry_run=True, no_orphans=False, full=False, config=None)
apply(dry_run=True, no_orphans=False, no_strays=False, full=False, config=None)
captured = capsys.readouterr()
assert "Stacks to migrate" in captured.out
@@ -112,6 +114,7 @@ class TestApplyCommand:
),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -120,7 +123,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=False, full=False, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
mock_up.assert_called_once()
call_args = mock_up.call_args
@@ -139,6 +142,7 @@ class TestApplyCommand:
),
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -146,7 +150,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=False, full=False, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
mock_stop.assert_called_once_with(cfg)
@@ -169,6 +173,7 @@ class TestApplyCommand:
),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -178,7 +183,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=True, full=False, config=None)
apply(dry_run=False, no_orphans=True, no_strays=False, full=False, config=None)
# Should run migrations but not orphan cleanup
mock_up.assert_called_once()
@@ -202,8 +207,9 @@ class TestApplyCommand:
),
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
):
apply(dry_run=False, no_orphans=True, full=False, config=None)
apply(dry_run=False, no_orphans=True, no_strays=False, full=False, config=None)
captured = capsys.readouterr()
assert "Nothing to apply" in captured.out
@@ -221,6 +227,7 @@ class TestApplyCommand:
"compose_farm.cli.lifecycle.get_stacks_not_in_state",
return_value=["svc1"],
),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -229,7 +236,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=False, full=False, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
mock_up.assert_called_once()
call_args = mock_up.call_args
@@ -249,8 +256,9 @@ class TestApplyCommand:
"compose_farm.cli.lifecycle.get_stacks_not_in_state",
return_value=["svc1"],
),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
):
apply(dry_run=True, no_orphans=False, full=False, config=None)
apply(dry_run=True, no_orphans=False, no_strays=False, full=False, config=None)
captured = capsys.readouterr()
assert "Stacks to start" in captured.out
@@ -267,6 +275,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -275,7 +284,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=False, full=True, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=True, config=None)
mock_up.assert_called_once()
call_args = mock_up.call_args
@@ -293,8 +302,9 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
):
apply(dry_run=True, no_orphans=False, full=True, config=None)
apply(dry_run=True, no_orphans=False, no_strays=False, full=True, config=None)
captured = capsys.readouterr()
assert "Stacks to refresh" in captured.out
@@ -319,6 +329,7 @@ class TestApplyCommand:
return_value=["svc2"],
),
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host2"),
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=mock_results,
@@ -327,7 +338,7 @@ class TestApplyCommand:
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
apply(dry_run=False, no_orphans=False, full=True, config=None)
apply(dry_run=False, no_orphans=False, no_strays=False, full=True, config=None)
# up_stacks should be called 3 times: migrate, start, refresh
assert mock_up.call_count == 3

269
tests/test_containers.py Normal file
View File

@@ -0,0 +1,269 @@
"""Tests for Containers page routes."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from compose_farm.config import Config, Host
from compose_farm.glances import ContainerStats
from compose_farm.web.app import create_app
from compose_farm.web.routes.containers import (
_format_bytes,
_infer_stack_service,
_parse_image,
_parse_uptime_seconds,
)
# Byte size constants for tests
KB = 1024
MB = KB * 1024
GB = MB * 1024
class TestFormatBytes:
"""Tests for _format_bytes function (uses humanize library)."""
def test_bytes(self) -> None:
assert _format_bytes(500) == "500 Bytes"
assert _format_bytes(0) == "0 Bytes"
def test_kilobytes(self) -> None:
assert _format_bytes(KB) == "1.0 KiB"
assert _format_bytes(KB * 5) == "5.0 KiB"
assert _format_bytes(KB + 512) == "1.5 KiB"
def test_megabytes(self) -> None:
assert _format_bytes(MB) == "1.0 MiB"
assert _format_bytes(MB * 100) == "100.0 MiB"
assert _format_bytes(MB * 512) == "512.0 MiB"
def test_gigabytes(self) -> None:
assert _format_bytes(GB) == "1.0 GiB"
assert _format_bytes(GB * 2) == "2.0 GiB"
class TestParseImage:
"""Tests for _parse_image function."""
def test_simple_image_with_tag(self) -> None:
assert _parse_image("nginx:latest") == ("nginx", "latest")
assert _parse_image("redis:7") == ("redis", "7")
def test_image_without_tag(self) -> None:
assert _parse_image("nginx") == ("nginx", "latest")
def test_registry_image(self) -> None:
assert _parse_image("ghcr.io/user/repo:v1.0") == ("ghcr.io/user/repo", "v1.0")
assert _parse_image("docker.io/library/nginx:alpine") == (
"docker.io/library/nginx",
"alpine",
)
def test_image_with_port_in_registry(self) -> None:
# Registry with port should not be confused with tag
assert _parse_image("localhost:5000/myimage") == ("localhost:5000/myimage", "latest")
class TestParseUptimeSeconds:
"""Tests for _parse_uptime_seconds function."""
def test_seconds(self) -> None:
assert _parse_uptime_seconds("17 seconds") == 17
assert _parse_uptime_seconds("1 second") == 1
def test_minutes(self) -> None:
assert _parse_uptime_seconds("5 minutes") == 300
assert _parse_uptime_seconds("1 minute") == 60
def test_hours(self) -> None:
assert _parse_uptime_seconds("2 hours") == 7200
assert _parse_uptime_seconds("an hour") == 3600
assert _parse_uptime_seconds("1 hour") == 3600
def test_days(self) -> None:
assert _parse_uptime_seconds("3 days") == 259200
assert _parse_uptime_seconds("a day") == 86400
def test_empty(self) -> None:
assert _parse_uptime_seconds("") == 0
assert _parse_uptime_seconds("-") == 0
class TestInferStackService:
"""Tests for _infer_stack_service function."""
def test_underscore_separator(self) -> None:
assert _infer_stack_service("mystack_web_1") == ("mystack", "web")
assert _infer_stack_service("app_db_1") == ("app", "db")
def test_hyphen_separator(self) -> None:
assert _infer_stack_service("mystack-web-1") == ("mystack", "web")
assert _infer_stack_service("compose-farm-api-1") == ("compose", "farm-api")
def test_simple_name(self) -> None:
# No separator - use name for both
assert _infer_stack_service("nginx") == ("nginx", "nginx")
assert _infer_stack_service("traefik") == ("traefik", "traefik")
def test_single_part_with_separator(self) -> None:
# Edge case: separator with empty second part
assert _infer_stack_service("single_") == ("single", "")
class TestContainersPage:
"""Tests for containers page endpoint."""
@pytest.fixture
def client(self) -> TestClient:
app = create_app()
return TestClient(app)
@pytest.fixture
def mock_config(self) -> Config:
return Config(
compose_dir=Path("/opt/compose"),
hosts={
"nas": Host(address="192.168.1.6"),
"nuc": Host(address="192.168.1.2"),
},
stacks={"test": "nas"},
glances_stack="glances",
)
def test_containers_page_without_glances(self, client: TestClient) -> None:
"""Test containers page shows warning when Glances not configured."""
with patch("compose_farm.web.routes.containers.get_config") as mock:
mock.return_value = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"test": "nas"},
glances_stack=None,
)
response = client.get("/live-stats")
assert response.status_code == 200
assert "Glances not configured" in response.text
def test_containers_page_with_glances(self, client: TestClient, mock_config: Config) -> None:
"""Test containers page loads when Glances is configured."""
with patch("compose_farm.web.routes.containers.get_config") as mock:
mock.return_value = mock_config
response = client.get("/live-stats")
assert response.status_code == 200
assert "Live Stats" in response.text
assert "container-rows" in response.text
class TestContainersRowsAPI:
"""Tests for containers rows HTML endpoint."""
@pytest.fixture
def client(self) -> TestClient:
app = create_app()
return TestClient(app)
def test_rows_without_glances(self, client: TestClient) -> None:
"""Test rows endpoint returns error when Glances not configured."""
with patch("compose_farm.web.routes.containers.get_config") as mock:
mock.return_value = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"test": "nas"},
glances_stack=None,
)
response = client.get("/api/containers/rows")
assert response.status_code == 200
assert "Glances not configured" in response.text
def test_rows_returns_html(self, client: TestClient) -> None:
"""Test rows endpoint returns HTML table rows."""
mock_containers = [
ContainerStats(
name="nginx",
host="nas",
status="running",
image="nginx:latest",
cpu_percent=5.5,
memory_usage=104857600,
memory_limit=1073741824,
memory_percent=9.77,
network_rx=1000,
network_tx=500,
uptime="2 hours",
ports="80->80/tcp",
engine="docker",
stack="web",
service="nginx",
),
]
with (
patch("compose_farm.web.routes.containers.get_config") as mock_config,
patch(
"compose_farm.web.routes.containers.fetch_all_container_stats",
new_callable=AsyncMock,
) as mock_fetch,
):
mock_config.return_value = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"test": "nas"},
glances_stack="glances",
)
mock_fetch.return_value = mock_containers
response = client.get("/api/containers/rows")
assert response.status_code == 200
assert "<tr " in response.text # <tr id="..."> has attributes
assert "nginx" in response.text
assert "running" in response.text
def test_rows_have_data_sort_attributes(self, client: TestClient) -> None:
"""Test rows have data-sort attributes for client-side sorting."""
mock_containers = [
ContainerStats(
name="alpha",
host="nas",
status="running",
image="nginx:latest",
cpu_percent=10.0,
memory_usage=100,
memory_limit=1000,
memory_percent=10.0,
network_rx=100,
network_tx=100,
uptime="1 hour",
ports="",
engine="docker",
stack="alpha",
service="web",
),
]
with (
patch("compose_farm.web.routes.containers.get_config") as mock_config,
patch(
"compose_farm.web.routes.containers.fetch_all_container_stats",
new_callable=AsyncMock,
) as mock_fetch,
):
mock_config.return_value = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"test": "nas"},
glances_stack="glances",
)
mock_fetch.return_value = mock_containers
response = client.get("/api/containers/rows")
assert response.status_code == 200
# Check that cells have data-sort attributes
assert 'data-sort="alpha"' in response.text # stack
assert 'data-sort="web"' in response.text # service
assert 'data-sort="3600"' in response.text # uptime (1 hour = 3600s)
assert 'data-sort="10' in response.text # cpu

View File

@@ -11,6 +11,7 @@ from compose_farm.executor import (
_run_local_command,
check_networks_exist,
check_paths_exist,
get_running_stacks_on_host,
is_local,
run_command,
run_compose,
@@ -239,3 +240,31 @@ class TestCheckNetworksExist:
result = await check_networks_exist(config, "local", [])
assert result == {}
@linux_only
class TestGetRunningStacksOnHost:
"""Tests for get_running_stacks_on_host function (requires Docker)."""
async def test_returns_set_of_stacks(self, tmp_path: Path) -> None:
"""Function returns a set of stack names."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await get_running_stacks_on_host(config, "local")
assert isinstance(result, set)
async def test_filters_empty_lines(self, tmp_path: Path) -> None:
"""Empty project names are filtered out."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
# Result should not contain empty strings
result = await get_running_stacks_on_host(config, "local")
assert "" not in result

349
tests/test_glances.py Normal file
View File

@@ -0,0 +1,349 @@
"""Tests for Glances integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from compose_farm.config import Config, Host
from compose_farm.glances import (
DEFAULT_GLANCES_PORT,
ContainerStats,
HostStats,
fetch_all_container_stats,
fetch_all_host_stats,
fetch_container_stats,
fetch_host_stats,
)
class TestHostStats:
"""Tests for HostStats dataclass."""
def test_host_stats_creation(self) -> None:
stats = HostStats(
host="nas",
cpu_percent=25.5,
mem_percent=50.0,
swap_percent=10.0,
load=2.5,
disk_percent=75.0,
)
assert stats.host == "nas"
assert stats.cpu_percent == 25.5
assert stats.mem_percent == 50.0
assert stats.disk_percent == 75.0
assert stats.error is None
def test_host_stats_from_error(self) -> None:
stats = HostStats.from_error("nas", "Connection refused")
assert stats.host == "nas"
assert stats.cpu_percent == 0
assert stats.mem_percent == 0
assert stats.error == "Connection refused"
class TestFetchHostStats:
"""Tests for fetch_host_stats function."""
@pytest.mark.asyncio
async def test_fetch_host_stats_success(self) -> None:
quicklook_response = httpx.Response(
200,
json={
"cpu": 25.5,
"mem": 50.0,
"swap": 5.0,
"load": 2.5,
},
)
fs_response = httpx.Response(
200,
json=[
{"mnt_point": "/", "percent": 65.0},
{"mnt_point": "/mnt/data", "percent": 80.0},
],
)
async def mock_get(url: str) -> httpx.Response:
if "quicklook" in url:
return quicklook_response
return fs_response
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=mock_get)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.cpu_percent == 25.5
assert stats.mem_percent == 50.0
assert stats.swap_percent == 5.0
assert stats.load == 2.5
assert stats.disk_percent == 65.0 # Root filesystem
assert stats.error is None
@pytest.mark.asyncio
async def test_fetch_host_stats_http_error(self) -> None:
mock_response = httpx.Response(500)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error == "HTTP 500"
assert stats.cpu_percent == 0
@pytest.mark.asyncio
async def test_fetch_host_stats_timeout(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error == "timeout"
@pytest.mark.asyncio
async def test_fetch_host_stats_connection_error(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error is not None
assert "Connection refused" in stats.error
class TestFetchAllHostStats:
"""Tests for fetch_all_host_stats function."""
@pytest.mark.asyncio
async def test_fetch_all_host_stats(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={
"nas": Host(address="192.168.1.6"),
"nuc": Host(address="192.168.1.2"),
},
stacks={"test": "nas"},
)
quicklook_response = httpx.Response(
200,
json={
"cpu": 25.5,
"mem": 50.0,
"swap": 5.0,
"load": 2.5,
},
)
fs_response = httpx.Response(
200,
json=[{"mnt_point": "/", "percent": 70.0}],
)
async def mock_get(url: str) -> httpx.Response:
if "quicklook" in url:
return quicklook_response
return fs_response
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=mock_get)
stats = await fetch_all_host_stats(config)
assert "nas" in stats
assert "nuc" in stats
assert stats["nas"].cpu_percent == 25.5
assert stats["nuc"].cpu_percent == 25.5
assert stats["nas"].disk_percent == 70.0
class TestDefaultPort:
"""Tests for default Glances port constant."""
def test_default_port(self) -> None:
assert DEFAULT_GLANCES_PORT == 61208
class TestContainerStats:
"""Tests for ContainerStats dataclass."""
def test_container_stats_creation(self) -> None:
stats = ContainerStats(
name="nginx",
host="nas",
status="running",
image="nginx:latest",
cpu_percent=5.5,
memory_usage=104857600, # 100MB
memory_limit=1073741824, # 1GB
memory_percent=9.77,
network_rx=1000000,
network_tx=500000,
uptime="2 hours",
ports="80->80/tcp",
engine="docker",
)
assert stats.name == "nginx"
assert stats.host == "nas"
assert stats.cpu_percent == 5.5
class TestFetchContainerStats:
"""Tests for fetch_container_stats function."""
@pytest.mark.asyncio
async def test_fetch_container_stats_success(self) -> None:
mock_response = httpx.Response(
200,
json=[
{
"name": "nginx",
"status": "running",
"image": ["nginx:latest"],
"cpu_percent": 5.5,
"memory_usage": 104857600,
"memory_limit": 1073741824,
"network": {"cumulative_rx": 1000, "cumulative_tx": 500},
"uptime": "2 hours",
"ports": "80->80/tcp",
"engine": "docker",
},
{
"name": "redis",
"status": "running",
"image": ["redis:7"],
"cpu_percent": 1.2,
"memory_usage": 52428800,
"memory_limit": 1073741824,
"network": {},
"uptime": "3 hours",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert error is None
assert containers is not None
assert len(containers) == 2
assert containers[0].name == "nginx"
assert containers[0].host == "nas"
assert containers[0].cpu_percent == 5.5
assert containers[1].name == "redis"
@pytest.mark.asyncio
async def test_fetch_container_stats_empty_on_error(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert containers is None
assert error == "Connection timed out"
@pytest.mark.asyncio
async def test_fetch_container_stats_handles_string_image(self) -> None:
"""Test that image field works as string (not just list)."""
mock_response = httpx.Response(
200,
json=[
{
"name": "test",
"status": "running",
"image": "myimage:v1", # String instead of list
"cpu_percent": 0,
"memory_usage": 0,
"memory_limit": 1,
"network": {},
"uptime": "",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert error is None
assert containers is not None
assert len(containers) == 1
assert containers[0].image == "myimage:v1"
class TestFetchAllContainerStats:
"""Tests for fetch_all_container_stats function."""
@pytest.mark.asyncio
async def test_fetch_all_container_stats(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={
"nas": Host(address="192.168.1.6"),
"nuc": Host(address="192.168.1.2"),
},
stacks={"test": "nas"},
)
mock_response = httpx.Response(
200,
json=[
{
"name": "nginx",
"status": "running",
"image": ["nginx:latest"],
"cpu_percent": 5.5,
"memory_usage": 104857600,
"memory_limit": 1073741824,
"network": {},
"uptime": "2 hours",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers = await fetch_all_container_stats(config)
# 2 hosts x 1 container each = 2 containers
assert len(containers) == 2
hosts = {c.host for c in containers}
assert "nas" in hosts
assert "nuc" in hosts

View File

@@ -10,8 +10,8 @@ import pytest
from compose_farm.config import Config, Host
from compose_farm.executor import CommandResult
from compose_farm.logs import (
_parse_images_output,
collect_stack_entries,
_SECTION_SEPARATOR,
collect_stacks_entries_on_host,
isoformat,
load_existing_entries,
merge_entries,
@@ -19,74 +19,252 @@ from compose_farm.logs import (
)
def test_parse_images_output_handles_list_and_lines() -> None:
data = [
{"Service": "svc", "Image": "redis", "Digest": "sha256:abc"},
{"Service": "svc", "Image": "db", "Digest": "sha256:def"},
def _make_mock_output(
project_images: dict[str, list[str]], image_info: list[dict[str, object]]
) -> str:
"""Build mock output matching the 2-docker-command format."""
# Section 1: project|image pairs from docker ps
ps_lines = [
f"{project}|{image}" for project, images in project_images.items() for image in images
]
as_array = _parse_images_output(json.dumps(data))
assert len(as_array) == 2
as_lines = _parse_images_output("\n".join(json.dumps(item) for item in data))
assert len(as_lines) == 2
# Section 2: JSON array from docker image inspect
image_json = json.dumps(image_info)
return f"{chr(10).join(ps_lines)}\n{_SECTION_SEPARATOR}\n{image_json}"
@pytest.mark.asyncio
async def test_snapshot_preserves_first_seen(tmp_path: Path) -> None:
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
stack_dir = compose_dir / "svc"
stack_dir.mkdir()
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
class TestCollectStacksEntriesOnHost:
"""Tests for collect_stacks_entries_on_host (2 docker commands per host)."""
config = Config(
compose_dir=compose_dir,
hosts={"local": Host(address="localhost")},
stacks={"svc": "local"},
)
@pytest.fixture
def config_with_stacks(self, tmp_path: Path) -> Config:
"""Create a config with multiple stacks."""
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
for stack in ["plex", "jellyfin", "sonarr"]:
stack_dir = compose_dir / stack
stack_dir.mkdir()
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
sample_output = json.dumps([{"Service": "svc", "Image": "redis", "Digest": "sha256:abc"}])
async def fake_run_compose(
_cfg: Config, stack: str, compose_cmd: str, *, stream: bool = True
) -> CommandResult:
assert compose_cmd == "images --format json"
assert stream is False or stream is True
return CommandResult(
stack=stack,
exit_code=0,
success=True,
stdout=sample_output,
stderr="",
return Config(
compose_dir=compose_dir,
hosts={"host1": Host(address="localhost"), "host2": Host(address="localhost")},
stacks={"plex": "host1", "jellyfin": "host1", "sonarr": "host2"},
)
log_path = tmp_path / "dockerfarm-log.toml"
@pytest.mark.asyncio
async def test_single_ssh_call(
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify only 1 SSH call is made regardless of stack count."""
call_count = {"count": 0}
# First snapshot
first_time = datetime(2025, 1, 1, tzinfo=UTC)
first_entries = await collect_stack_entries(
config, "svc", now=first_time, run_compose_fn=fake_run_compose
)
first_iso = isoformat(first_time)
merged = merge_entries([], first_entries, now_iso=first_iso)
meta = {"generated_at": first_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
async def mock_run_command(
host: Host, command: str, stack: str, *, stream: bool, prefix: str
) -> CommandResult:
call_count["count"] += 1
output = _make_mock_output(
{"plex": ["plex:latest"], "jellyfin": ["jellyfin:latest"]},
[
{
"RepoTags": ["plex:latest"],
"Id": "sha256:aaa",
"RepoDigests": ["plex@sha256:aaa"],
},
{
"RepoTags": ["jellyfin:latest"],
"Id": "sha256:bbb",
"RepoDigests": ["jellyfin@sha256:bbb"],
},
],
)
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
after_first = tomllib.loads(log_path.read_text())
first_seen = after_first["entries"][0]["first_seen"]
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
# Second snapshot
second_time = datetime(2025, 2, 1, tzinfo=UTC)
second_entries = await collect_stack_entries(
config, "svc", now=second_time, run_compose_fn=fake_run_compose
)
second_iso = isoformat(second_time)
existing = load_existing_entries(log_path)
merged = merge_entries(existing, second_entries, now_iso=second_iso)
meta = {"generated_at": second_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
now = datetime(2025, 1, 1, tzinfo=UTC)
entries = await collect_stacks_entries_on_host(
config_with_stacks, "host1", {"plex", "jellyfin"}, now=now
)
after_second = tomllib.loads(log_path.read_text())
entry = after_second["entries"][0]
assert entry["first_seen"] == first_seen
assert entry["last_seen"].startswith("2025-02-01")
assert call_count["count"] == 1
assert len(entries) == 2
@pytest.mark.asyncio
async def test_filters_to_requested_stacks(
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only return entries for stacks we asked for, even if others are running."""
async def mock_run_command(
host: Host, command: str, stack: str, *, stream: bool, prefix: str
) -> CommandResult:
# Docker ps shows 3 stacks, but we only want plex
output = _make_mock_output(
{
"plex": ["plex:latest"],
"jellyfin": ["jellyfin:latest"],
"other": ["other:latest"],
},
[
{
"RepoTags": ["plex:latest"],
"Id": "sha256:aaa",
"RepoDigests": ["plex@sha256:aaa"],
},
{
"RepoTags": ["jellyfin:latest"],
"Id": "sha256:bbb",
"RepoDigests": ["j@sha256:bbb"],
},
{
"RepoTags": ["other:latest"],
"Id": "sha256:ccc",
"RepoDigests": ["o@sha256:ccc"],
},
],
)
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
now = datetime(2025, 1, 1, tzinfo=UTC)
entries = await collect_stacks_entries_on_host(
config_with_stacks, "host1", {"plex"}, now=now
)
assert len(entries) == 1
assert entries[0].stack == "plex"
@pytest.mark.asyncio
async def test_multiple_images_per_stack(
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Stack with multiple containers/images returns multiple entries."""
async def mock_run_command(
host: Host, command: str, stack: str, *, stream: bool, prefix: str
) -> CommandResult:
output = _make_mock_output(
{"plex": ["plex:latest", "redis:7"]},
[
{
"RepoTags": ["plex:latest"],
"Id": "sha256:aaa",
"RepoDigests": ["p@sha256:aaa"],
},
{"RepoTags": ["redis:7"], "Id": "sha256:bbb", "RepoDigests": ["r@sha256:bbb"]},
],
)
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
now = datetime(2025, 1, 1, tzinfo=UTC)
entries = await collect_stacks_entries_on_host(
config_with_stacks, "host1", {"plex"}, now=now
)
assert len(entries) == 2
images = {e.image for e in entries}
assert images == {"plex:latest", "redis:7"}
@pytest.mark.asyncio
async def test_empty_stacks_returns_empty(self, config_with_stacks: Config) -> None:
"""Empty stack set returns empty entries without making SSH call."""
now = datetime(2025, 1, 1, tzinfo=UTC)
entries = await collect_stacks_entries_on_host(config_with_stacks, "host1", set(), now=now)
assert entries == []
@pytest.mark.asyncio
async def test_ssh_failure_returns_empty(
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
"""SSH failure returns empty list instead of raising."""
async def mock_run_command(
host: Host, command: str, stack: str, *, stream: bool, prefix: str
) -> CommandResult:
return CommandResult(stack=stack, exit_code=1, success=False, stdout="", stderr="error")
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
now = datetime(2025, 1, 1, tzinfo=UTC)
entries = await collect_stacks_entries_on_host(
config_with_stacks, "host1", {"plex"}, now=now
)
assert entries == []
class TestSnapshotMerging:
"""Tests for merge_entries preserving first_seen."""
@pytest.fixture
def config(self, tmp_path: Path) -> Config:
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
stack_dir = compose_dir / "svc"
stack_dir.mkdir()
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
return Config(
compose_dir=compose_dir,
hosts={"local": Host(address="localhost")},
stacks={"svc": "local"},
)
@pytest.mark.asyncio
async def test_preserves_first_seen(
self, tmp_path: Path, config: Config, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Repeated snapshots preserve first_seen timestamp."""
async def mock_run_command(
host: Host, command: str, stack: str, *, stream: bool, prefix: str
) -> CommandResult:
output = _make_mock_output(
{"svc": ["redis:latest"]},
[
{
"RepoTags": ["redis:latest"],
"Id": "sha256:abc",
"RepoDigests": ["r@sha256:abc"],
}
],
)
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
log_path = tmp_path / "dockerfarm-log.toml"
# First snapshot
first_time = datetime(2025, 1, 1, tzinfo=UTC)
first_entries = await collect_stacks_entries_on_host(
config, "local", {"svc"}, now=first_time
)
first_iso = isoformat(first_time)
merged = merge_entries([], first_entries, now_iso=first_iso)
meta = {"generated_at": first_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
after_first = tomllib.loads(log_path.read_text())
first_seen = after_first["entries"][0]["first_seen"]
# Second snapshot
second_time = datetime(2025, 2, 1, tzinfo=UTC)
second_entries = await collect_stacks_entries_on_host(
config, "local", {"svc"}, now=second_time
)
second_iso = isoformat(second_time)
existing = load_existing_entries(log_path)
merged = merge_entries(existing, second_entries, now_iso=second_iso)
meta = {"generated_at": second_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
after_second = tomllib.loads(log_path.read_text())
entry = after_second["entries"][0]
assert entry["first_seen"] == first_seen
assert entry["last_seen"].startswith("2025-02-01")

View File

@@ -11,7 +11,10 @@ import pytest
from compose_farm.cli import lifecycle
from compose_farm.config import Config, Host
from compose_farm.executor import CommandResult
from compose_farm.operations import _migrate_stack
from compose_farm.operations import (
_migrate_stack,
build_discovery_results,
)
@pytest.fixture
@@ -109,3 +112,83 @@ class TestUpdateCommandSequence:
# Verify the sequence is pull, build, down, up
assert "down" in source
assert "up -d" in source
class TestBuildDiscoveryResults:
"""Tests for build_discovery_results function."""
@pytest.fixture
def config(self, tmp_path: Path) -> Config:
"""Create a test config with multiple stacks."""
compose_dir = tmp_path / "compose"
for stack in ["plex", "jellyfin", "sonarr"]:
(compose_dir / stack).mkdir(parents=True)
(compose_dir / stack / "docker-compose.yml").write_text("services: {}")
return Config(
compose_dir=compose_dir,
hosts={
"host1": Host(address="localhost"),
"host2": Host(address="localhost"),
},
stacks={"plex": "host1", "jellyfin": "host1", "sonarr": "host2"},
)
def test_discovers_correctly_running_stacks(self, config: Config) -> None:
"""Stacks running on correct hosts are discovered."""
running_on_host = {
"host1": {"plex", "jellyfin"},
"host2": {"sonarr"},
}
discovered, strays, duplicates = build_discovery_results(config, running_on_host)
assert discovered == {"plex": "host1", "jellyfin": "host1", "sonarr": "host2"}
assert strays == {}
assert duplicates == {}
def test_detects_stray_stacks(self, config: Config) -> None:
"""Stacks running on wrong hosts are marked as strays."""
running_on_host = {
"host1": set(),
"host2": {"plex"}, # plex should be on host1
}
discovered, strays, _duplicates = build_discovery_results(config, running_on_host)
assert "plex" not in discovered
assert strays == {"plex": ["host2"]}
def test_detects_duplicates(self, config: Config) -> None:
"""Single-host stacks running on multiple hosts are duplicates."""
running_on_host = {
"host1": {"plex"},
"host2": {"plex"}, # plex running on both hosts
}
discovered, strays, duplicates = build_discovery_results(
config, running_on_host, stacks=["plex"]
)
# plex is correctly running on host1
assert discovered == {"plex": "host1"}
# plex is also a stray on host2
assert strays == {"plex": ["host2"]}
# plex is a duplicate (single-host stack on multiple hosts)
assert duplicates == {"plex": ["host1", "host2"]}
def test_filters_to_requested_stacks(self, config: Config) -> None:
"""Only returns results for requested stacks."""
running_on_host = {
"host1": {"plex", "jellyfin"},
"host2": {"sonarr"},
}
discovered, _strays, _duplicates = build_discovery_results(
config, running_on_host, stacks=["plex"]
)
# Only plex should be in results
assert discovered == {"plex": "host1"}
assert "jellyfin" not in discovered
assert "sonarr" not in discovered

View File

@@ -211,8 +211,8 @@ class TestRefreshCommand:
return_value=existing_state,
),
patch(
"compose_farm.cli.management._discover_stacks",
return_value={"plex": "nas02"}, # plex moved to nas02
"compose_farm.cli.management._discover_stacks_full",
return_value=({"plex": "nas02"}, {}, {}), # plex moved to nas02
),
patch("compose_farm.cli.management._snapshot_stacks"),
patch("compose_farm.cli.management.save_state") as mock_save,
@@ -247,8 +247,12 @@ class TestRefreshCommand:
return_value=existing_state,
),
patch(
"compose_farm.cli.management._discover_stacks",
return_value={"plex": "nas01", "grafana": "nas02"}, # jellyfin not running
"compose_farm.cli.management._discover_stacks_full",
return_value=(
{"plex": "nas01", "grafana": "nas02"},
{},
{},
), # jellyfin not running
),
patch("compose_farm.cli.management._snapshot_stacks"),
patch("compose_farm.cli.management.save_state") as mock_save,
@@ -281,8 +285,8 @@ class TestRefreshCommand:
return_value=existing_state,
),
patch(
"compose_farm.cli.management._discover_stacks",
return_value={"plex": "nas01"}, # only plex running
"compose_farm.cli.management._discover_stacks_full",
return_value=({"plex": "nas01"}, {}, {}), # only plex running
),
patch("compose_farm.cli.management._snapshot_stacks"),
patch("compose_farm.cli.management.save_state") as mock_save,
@@ -315,8 +319,8 @@ class TestRefreshCommand:
return_value=existing_state,
),
patch(
"compose_farm.cli.management._discover_stacks",
return_value={"plex": "nas01"}, # jellyfin not running
"compose_farm.cli.management._discover_stacks_full",
return_value=({"plex": "nas01"}, {}, {}), # jellyfin not running
),
patch("compose_farm.cli.management._snapshot_stacks"),
patch("compose_farm.cli.management.save_state") as mock_save,
@@ -350,8 +354,8 @@ class TestRefreshCommand:
return_value=existing_state,
),
patch(
"compose_farm.cli.management._discover_stacks",
return_value={"plex": "nas02"}, # would change
"compose_farm.cli.management._discover_stacks_full",
return_value=({"plex": "nas02"}, {}, {}), # would change
),
patch("compose_farm.cli.management.save_state") as mock_save,
):

182
tests/test_registry.py Normal file
View File

@@ -0,0 +1,182 @@
"""Tests for registry module."""
from compose_farm.registry import (
DOCKER_HUB_ALIASES,
ImageRef,
RegistryClient,
TagCheckResult,
_find_updates,
_parse_version,
)
class TestImageRef:
"""Tests for ImageRef parsing."""
def test_parse_simple_image(self) -> None:
"""Test parsing simple image name."""
ref = ImageRef.parse("nginx")
assert ref.registry == "docker.io"
assert ref.namespace == "library"
assert ref.name == "nginx"
assert ref.tag == "latest"
def test_parse_image_with_tag(self) -> None:
"""Test parsing image with tag."""
ref = ImageRef.parse("nginx:1.25")
assert ref.registry == "docker.io"
assert ref.namespace == "library"
assert ref.name == "nginx"
assert ref.tag == "1.25"
def test_parse_image_with_namespace(self) -> None:
"""Test parsing image with namespace."""
ref = ImageRef.parse("linuxserver/jellyfin:latest")
assert ref.registry == "docker.io"
assert ref.namespace == "linuxserver"
assert ref.name == "jellyfin"
assert ref.tag == "latest"
def test_parse_ghcr_image(self) -> None:
"""Test parsing GitHub Container Registry image."""
ref = ImageRef.parse("ghcr.io/user/repo:v1.0.0")
assert ref.registry == "ghcr.io"
assert ref.namespace == "user"
assert ref.name == "repo"
assert ref.tag == "v1.0.0"
def test_parse_image_with_digest(self) -> None:
"""Test parsing image with digest."""
ref = ImageRef.parse("nginx:latest@sha256:abc123")
assert ref.registry == "docker.io"
assert ref.name == "nginx"
assert ref.tag == "latest"
assert ref.digest == "sha256:abc123"
def test_full_name_with_namespace(self) -> None:
"""Test full_name property with namespace."""
ref = ImageRef.parse("linuxserver/jellyfin")
assert ref.full_name == "linuxserver/jellyfin"
def test_full_name_without_namespace(self) -> None:
"""Test full_name property for official images."""
ref = ImageRef.parse("nginx")
assert ref.full_name == "library/nginx"
def test_display_name_official_image(self) -> None:
"""Test display_name for official Docker Hub images."""
ref = ImageRef.parse("nginx:latest")
assert ref.display_name == "nginx"
def test_display_name_hub_with_namespace(self) -> None:
"""Test display_name for Docker Hub images with namespace."""
ref = ImageRef.parse("linuxserver/jellyfin")
assert ref.display_name == "linuxserver/jellyfin"
def test_display_name_other_registry(self) -> None:
"""Test display_name for other registries."""
ref = ImageRef.parse("ghcr.io/user/repo")
assert ref.display_name == "ghcr.io/user/repo"
class TestParseVersion:
"""Tests for version parsing."""
def test_parse_semver(self) -> None:
"""Test parsing semantic version."""
assert _parse_version("1.2.3") == (1, 2, 3)
def test_parse_version_with_v_prefix(self) -> None:
"""Test parsing version with v prefix."""
assert _parse_version("v1.2.3") == (1, 2, 3)
assert _parse_version("V1.2.3") == (1, 2, 3)
def test_parse_two_part_version(self) -> None:
"""Test parsing two-part version."""
assert _parse_version("1.25") == (1, 25)
def test_parse_single_number(self) -> None:
"""Test parsing single number version."""
assert _parse_version("7") == (7,)
def test_parse_invalid_version(self) -> None:
"""Test parsing non-version tags."""
assert _parse_version("latest") is None
assert _parse_version("stable") is None
assert _parse_version("alpine") is None
class TestFindUpdates:
"""Tests for finding available updates."""
def test_find_updates_with_newer_versions(self) -> None:
"""Test finding newer versions."""
current = "1.0.0"
tags = ["0.9.0", "1.0.0", "1.1.0", "2.0.0"]
updates = _find_updates(current, tags)
assert updates == ["2.0.0", "1.1.0"]
def test_find_updates_no_newer(self) -> None:
"""Test when already on latest."""
current = "2.0.0"
tags = ["1.0.0", "1.5.0", "2.0.0"]
updates = _find_updates(current, tags)
assert updates == []
def test_find_updates_non_version_tag(self) -> None:
"""Test with non-version current tag."""
current = "latest"
tags = ["1.0.0", "2.0.0"]
updates = _find_updates(current, tags)
# Can't determine updates for non-version tags
assert updates == []
class TestRegistryClient:
"""Tests for unified registry client."""
def test_docker_hub_normalization(self) -> None:
"""Test Docker Hub aliases are normalized."""
for alias in DOCKER_HUB_ALIASES:
client = RegistryClient(alias)
assert client.registry == "docker.io"
assert client.registry_url == "https://registry-1.docker.io"
def test_ghcr_client(self) -> None:
"""Test GitHub Container Registry client."""
client = RegistryClient("ghcr.io")
assert client.registry == "ghcr.io"
assert client.registry_url == "https://ghcr.io"
def test_generic_registry(self) -> None:
"""Test generic registry client."""
client = RegistryClient("quay.io")
assert client.registry == "quay.io"
assert client.registry_url == "https://quay.io"
class TestTagCheckResult:
"""Tests for TagCheckResult."""
def test_create_result(self) -> None:
"""Test creating a result."""
ref = ImageRef.parse("nginx:1.25")
result = TagCheckResult(
image=ref,
current_digest="sha256:abc",
available_updates=["1.26", "1.27"],
)
assert result.image.name == "nginx"
assert result.available_updates == ["1.26", "1.27"]
assert result.error is None
def test_result_with_error(self) -> None:
"""Test result with error."""
ref = ImageRef.parse("nginx")
result = TagCheckResult(
image=ref,
current_digest="",
error="Connection refused",
)
assert result.error == "Connection refused"
assert result.available_updates == []

View File

@@ -219,7 +219,7 @@ class TestSshConnectKwargs:
assert result["client_keys"] == [str(key_path)]
def test_includes_both_agent_and_key(self, tmp_path: Path) -> None:
"""Include both agent_path and client_keys when both available."""
"""Prioritize client_keys over agent_path when both available."""
host = Host(address="example.com")
key_path = tmp_path / "compose-farm"
@@ -229,7 +229,8 @@ class TestSshConnectKwargs:
):
result = ssh_connect_kwargs(host)
assert result["agent_path"] == "/tmp/agent.sock"
# Agent should be ignored in favor of the dedicated key
assert "agent_path" not in result
assert result["client_keys"] == [str(key_path)]
def test_custom_port(self) -> None:

View File

@@ -2,53 +2,65 @@
from pathlib import Path
import pytest
from compose_farm.web.routes.api import _backup_file, _save_with_backup
def test_backup_creates_timestamped_file(tmp_path: Path) -> None:
"""Test that backup creates file in .backups with correct content."""
test_file = tmp_path / "test.yaml"
@pytest.fixture
def xdg_backup_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Set XDG_CONFIG_HOME to tmp_path and return the backup directory path."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
return tmp_path / "compose-farm" / "backups"
def test_backup_creates_timestamped_file(tmp_path: Path, xdg_backup_dir: Path) -> None:
"""Test that backup creates file in XDG backup dir with correct content."""
test_file = tmp_path / "stacks" / "test.yaml"
test_file.parent.mkdir(parents=True)
test_file.write_text("original content")
backup_path = _backup_file(test_file)
assert backup_path is not None
assert backup_path.parent.name == ".backups"
assert backup_path.is_relative_to(xdg_backup_dir)
assert backup_path.name.startswith("test.yaml.")
assert backup_path.read_text() == "original content"
def test_backup_returns_none_for_nonexistent_file(tmp_path: Path) -> None:
def test_backup_returns_none_for_nonexistent_file(tmp_path: Path, xdg_backup_dir: Path) -> None:
"""Test that backup returns None if file doesn't exist."""
assert _backup_file(tmp_path / "nonexistent.yaml") is None
def test_save_creates_new_file(tmp_path: Path) -> None:
def test_save_creates_new_file(tmp_path: Path, xdg_backup_dir: Path) -> None:
"""Test that save creates new file without backup."""
test_file = tmp_path / "new.yaml"
assert _save_with_backup(test_file, "content") is True
assert test_file.read_text() == "content"
assert not (tmp_path / ".backups").exists()
assert not xdg_backup_dir.exists()
def test_save_skips_unchanged_content(tmp_path: Path) -> None:
def test_save_skips_unchanged_content(tmp_path: Path, xdg_backup_dir: Path) -> None:
"""Test that save returns False and creates no backup if unchanged."""
test_file = tmp_path / "test.yaml"
test_file.write_text("same")
assert _save_with_backup(test_file, "same") is False
assert not (tmp_path / ".backups").exists()
assert not xdg_backup_dir.exists()
def test_save_creates_backup_before_overwrite(tmp_path: Path) -> None:
def test_save_creates_backup_before_overwrite(tmp_path: Path, xdg_backup_dir: Path) -> None:
"""Test that save backs up original before overwriting."""
test_file = tmp_path / "test.yaml"
test_file = tmp_path / "stacks" / "test.yaml"
test_file.parent.mkdir(parents=True)
test_file.write_text("original")
assert _save_with_backup(test_file, "new") is True
assert test_file.read_text() == "new"
backups = list((tmp_path / ".backups").glob("test.yaml.*"))
# Find backup in XDG dir
backups = list(xdg_backup_dir.rglob("test.yaml.*"))
assert len(backups) == 1
assert backups[0].read_text() == "original"

View File

@@ -7,11 +7,58 @@ from typing import TYPE_CHECKING
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
if TYPE_CHECKING:
from compose_farm.config import Config
class TestExtractConfigError:
"""Tests for extract_config_error helper."""
def test_validation_error_with_location(self) -> None:
from compose_farm.config import Config, Host
from compose_farm.web.deps import extract_config_error
# Trigger a validation error with an extra field
with pytest.raises(ValidationError) as exc_info:
Config(
hosts={"server": Host(address="192.168.1.1")},
stacks={"app": "server"},
unknown_field="bad", # type: ignore[call-arg]
)
msg = extract_config_error(exc_info.value)
assert "unknown_field" in msg
assert "Extra inputs are not permitted" in msg
def test_validation_error_nested_location(self) -> None:
from compose_farm.config import Host
from compose_farm.web.deps import extract_config_error
# Trigger a validation error with a nested extra field
with pytest.raises(ValidationError) as exc_info:
Host(address="192.168.1.1", bad_key="value") # type: ignore[call-arg]
msg = extract_config_error(exc_info.value)
assert "bad_key" in msg
assert "Extra inputs are not permitted" in msg
def test_regular_exception(self) -> None:
from compose_farm.web.deps import extract_config_error
exc = ValueError("Something went wrong")
msg = extract_config_error(exc)
assert msg == "Something went wrong"
def test_file_not_found_exception(self) -> None:
from compose_farm.web.deps import extract_config_error
exc = FileNotFoundError("Config file not found")
msg = extract_config_error(exc)
assert msg == "Config file not found"
class TestValidateYaml:
"""Tests for _validate_yaml helper."""

View File

@@ -134,6 +134,13 @@ def test_config(tmp_path_factory: pytest.TempPathFactory) -> Path:
else:
(svc / "compose.yaml").write_text(f"services:\n {name}:\n image: test/{name}\n")
# Create glances stack (required for containers page)
glances_dir = compose_dir / "glances"
glances_dir.mkdir()
(glances_dir / "compose.yaml").write_text(
"services:\n glances:\n image: nicolargo/glances\n"
)
# Create config with multiple hosts
config = tmp / "compose-farm.yaml"
config.write_text(f"""
@@ -151,6 +158,8 @@ stacks:
nextcloud: server-2
jellyfin: server-2
redis: server-1
glances: all
glances_stack: glances
""")
# Create state (plex and nextcloud running, grafana and jellyfin not started)
@@ -245,7 +254,7 @@ class TestHTMXSidebarLoading:
# Verify actual stacks from test config appear
stacks = page.locator("#sidebar-stacks li")
assert stacks.count() == 5 # plex, grafana, nextcloud, jellyfin, redis
assert stacks.count() == 6 # plex, grafana, nextcloud, jellyfin, redis, glances
# Check specific stacks are present
content = page.locator("#sidebar-stacks").inner_text()
@@ -348,7 +357,7 @@ class TestDashboardContent:
# From test config: 2 hosts, 5 stacks, 2 running (plex, nextcloud)
assert "2" in stats # hosts count
assert "5" in stats # stacks count
assert "6" in stats # stacks count
def test_pending_shows_not_started_stacks(self, page: Page, server_url: str) -> None:
"""Pending operations shows grafana and jellyfin as not started."""
@@ -476,9 +485,9 @@ class TestSidebarFilter:
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Initially all 4 stacks visible
# Initially all 6 stacks visible
visible_items = page.locator("#sidebar-stacks li:not([hidden])")
assert visible_items.count() == 5
assert visible_items.count() == 6
# Type in filter to match only "plex"
self._filter_sidebar(page, "plex")
@@ -493,9 +502,9 @@ class TestSidebarFilter:
page.goto(server_url)
page.wait_for_selector("#sidebar-stacks", timeout=TIMEOUT)
# Initial count should be (5)
# Initial count should be (6)
count_badge = page.locator("#sidebar-count")
assert "(5)" in count_badge.inner_text()
assert "(6)" in count_badge.inner_text()
# Filter to show only stacks containing "x" (plex, nextcloud)
self._filter_sidebar(page, "x")
@@ -524,13 +533,14 @@ class TestSidebarFilter:
# Select server-1 from dropdown
page.locator("#sidebar-host-select").select_option("server-1")
# Only plex, grafana, and redis (server-1 stacks) should be visible
# plex, grafana, redis (server-1), and glances (all) should be visible
visible = page.locator("#sidebar-stacks li:not([hidden])")
assert visible.count() == 3
assert visible.count() == 4
content = visible.all_inner_texts()
assert any("plex" in s for s in content)
assert any("grafana" in s for s in content)
assert any("glances" 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)
@@ -562,7 +572,7 @@ class TestSidebarFilter:
self._filter_sidebar(page, "")
# All stacks visible again
assert page.locator("#sidebar-stacks li:not([hidden])").count() == 5
assert page.locator("#sidebar-stacks li:not([hidden])").count() == 6
class TestCommandPalette:
@@ -884,7 +894,7 @@ class TestContentStability:
# Remember sidebar state
initial_count = page.locator("#sidebar-stacks li").count()
assert initial_count == 5
assert initial_count == 6
# Navigate away
page.locator("#sidebar-stacks a", has_text="plex").click()
@@ -2329,3 +2339,227 @@ class TestTerminalNavigationIsolation:
# Terminal should still be collapsed (no task to reconnect to)
terminal_toggle = page.locator("#terminal-toggle")
assert not terminal_toggle.is_checked(), "Terminal should remain collapsed after navigation"
class TestContainersPagePause:
"""Test containers page auto-refresh pause mechanism.
The containers page auto-refreshes every 3 seconds. When a user opens
an action dropdown, refresh should pause to prevent the dropdown from
closing unexpectedly.
"""
# Mock HTML for container rows with action dropdowns
MOCK_ROWS_HTML = """
<tr>
<td>1</td>
<td data-sort="plex"><a href="/stack/plex" class="link">plex</a></td>
<td data-sort="server">server</td>
<td><div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-circle btn-ghost btn-xs"><svg class="h-4 w-4"></svg></label>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-200 rounded-box shadow-lg w-36 z-50 p-2">
<li><a hx-post="/api/stack/plex/restart">Restart</a></li>
</ul>
</div></td>
<td data-sort="nas"><span class="badge">nas</span></td>
<td data-sort="nginx:latest"><code>nginx:latest</code></td>
<td data-sort="running"><span class="badge badge-success">running</span></td>
<td data-sort="3600">1 hour</td>
<td data-sort="5"><progress class="progress" value="5" max="100"></progress><span>5%</span></td>
<td data-sort="104857600"><progress class="progress" value="10" max="100"></progress><span>100MB</span></td>
<td data-sort="1000">↓1KB ↑1KB</td>
</tr>
<tr>
<td>2</td>
<td data-sort="redis"><a href="/stack/redis" class="link">redis</a></td>
<td data-sort="redis">redis</td>
<td><div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-circle btn-ghost btn-xs"><svg class="h-4 w-4"></svg></label>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-200 rounded-box shadow-lg w-36 z-50 p-2">
<li><a hx-post="/api/stack/redis/restart">Restart</a></li>
</ul>
</div></td>
<td data-sort="nas"><span class="badge">nas</span></td>
<td data-sort="redis:7"><code>redis:7</code></td>
<td data-sort="running"><span class="badge badge-success">running</span></td>
<td data-sort="7200">2 hours</td>
<td data-sort="1"><progress class="progress" value="1" max="100"></progress><span>1%</span></td>
<td data-sort="52428800"><progress class="progress" value="5" max="100"></progress><span>50MB</span></td>
<td data-sort="500">↓500B ↑500B</td>
</tr>
"""
def test_dropdown_pauses_refresh(self, page: Page, server_url: str) -> None:
"""Opening action dropdown pauses auto-refresh.
Bug: focusin event triggers pause, but focusout fires shortly after
when focus moves within the dropdown, causing refresh to resume
while dropdown is still visually open.
"""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Click on a dropdown to open it
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
# Wait a moment for focusin to trigger
page.wait_for_timeout(200)
# Verify pause is engaged
timer_text = page.locator("#refresh-timer").inner_text()
assert timer_text == "❚❚", (
f"Refresh should be paused after clicking dropdown. timer='{timer_text}'"
)
assert "❚❚" in timer_text, f"Timer should show pause icon, got '{timer_text}'"
def test_refresh_stays_paused_while_dropdown_open(self, page: Page, server_url: str) -> None:
"""Refresh remains paused for duration dropdown is open (>5s refresh interval).
This is the critical test for the pause bug: refresh should stay paused
for longer than the 3-second refresh interval while dropdown is open.
"""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Record a marker in the first row to detect if refresh happened
page.evaluate("""
const firstRow = document.querySelector('#container-rows tr');
if (firstRow) firstRow.dataset.testMarker = 'original';
""")
# Click dropdown to pause
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
page.wait_for_timeout(200)
# Confirm paused
assert page.locator("#refresh-timer").inner_text() == "❚❚"
# Wait longer than the 5-second refresh interval
page.wait_for_timeout(6000)
# Check if still paused
timer_text = page.locator("#refresh-timer").inner_text()
# Check if the row was replaced (marker would be gone)
marker = page.evaluate("""
document.querySelector('#container-rows tr')?.dataset?.testMarker
""")
assert timer_text == "❚❚", f"Refresh should still be paused after 6s. timer='{timer_text}'"
assert marker == "original", (
"Table was refreshed while dropdown was open - pause mechanism failed"
)
def test_refresh_resumes_after_dropdown_closes(self, page: Page, server_url: str) -> None:
"""Refresh resumes after dropdown is closed."""
# Mock container rows and update checks
page.route(
"**/api/containers/rows/*",
lambda route: route.fulfill(
status=200,
content_type="text/html",
body=self.MOCK_ROWS_HTML,
),
)
page.route(
"**/api/containers/check-updates",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"results": []}',
),
)
page.goto(f"{server_url}/live-stats")
# Wait for container rows to load
page.wait_for_function(
"document.querySelectorAll('#container-rows tr:not(.loading-row)').length > 0",
timeout=TIMEOUT,
)
# Wait for timer to start
page.wait_for_function(
"document.getElementById('refresh-timer')?.textContent?.includes('')",
timeout=TIMEOUT,
)
# Click dropdown to pause
dropdown_label = page.locator(".dropdown label").first
dropdown_label.click()
page.wait_for_timeout(200)
assert page.locator("#refresh-timer").inner_text() == "❚❚"
# Close dropdown by pressing Escape or clicking elsewhere
page.keyboard.press("Escape")
page.wait_for_timeout(300) # Wait for focusout timeout (150ms) + buffer
# Verify refresh resumed
timer_text = page.locator("#refresh-timer").inner_text()
assert timer_text != "❚❚", (
f"Refresh should resume after closing dropdown. timer='{timer_text}'"
)
assert "" in timer_text, f"Timer should show countdown, got '{timer_text}'"

11
uv.lock generated
View File

@@ -242,6 +242,7 @@ dependencies = [
[package.optional-dependencies]
web = [
{ name = "fastapi", extra = ["standard"] },
{ name = "humanize" },
{ name = "jinja2" },
{ name = "websockets" },
]
@@ -270,6 +271,7 @@ dev = [
requires-dist = [
{ name = "asyncssh", specifier = ">=2.14.0" },
{ name = "fastapi", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.109.0" },
{ name = "humanize", marker = "extra == 'web'", specifier = ">=4.0.0" },
{ name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pyyaml", specifier = ">=6.0" },
@@ -781,6 +783,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "humanize"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" },
]
[[package]]
name = "identify"
version = "2.6.15"