Compare commits

...

9 Commits

Author SHA1 Message Date
Bas Nijholt
43de1ba8a9 Add additional SVG logo proposals 2026-01-05 01:31:24 -08:00
Bas Nijholt
b969c749be Add SVG recreation of current logo 2026-01-05 01:20:31 -08:00
Bas Nijholt
cfba027df6 Add minimal/geometric logo proposals
Eight new concepts with cleaner, modern aesthetic:
- Hexagon grid (honeycomb cluster)
- Container sprout (simple two-leaf)
- Stacked growth (containers + arrow)
- Negative space (plant cutout)
- CF monogram
- Abstract barn silhouette
- Dots grid (distributed containers)
- Single mark (container + corner leaf)
2026-01-05 01:17:42 -08:00
Bas Nijholt
18e5cc653b Add logo proposal SVGs
Six logo concepts combining farm imagery with Docker containers:
- Barn with container doors
- Container with wheat growing
- Silos styled as stacked containers
- Tractor pulling container trailer
- Harvest crate with container elements
- Minimal icon (container + plant)
2026-01-05 01:13:47 -08:00
Bas Nijholt
8dabc27272 update: Only restart containers when images change (#143)
* update: Only restart containers when images change

Use `up -d --pull always --build` instead of separate pull/build/down/up
steps. This avoids unnecessary container restarts when images haven't
changed.

* Update README.md

* docs: Update update command description across all docs

Reflect new behavior: only recreates containers if images changed.

* Update README.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 10:06:45 +01:00
Bas Nijholt
5e08f1d712 web: Exclude web stack from Update All button (#142) 2026-01-04 19:56:41 +01:00
Bas Nijholt
8302f1d97a Unify vendor assets configuration in single JSON file (#141) 2025-12-31 19:02:41 +01:00
Bas Nijholt
eac9338352 Sort web stack last in bulk operations to prevent self-restart interruption (#140) 2025-12-31 10:10:42 +01:00
Bas Nijholt
667931dc80 docs: Add release checklist to ensure on latest main (#139) 2025-12-30 08:01:19 +01:00
35 changed files with 1122 additions and 152 deletions

View File

@@ -110,6 +110,10 @@ Browser tests are marked with `@pytest.mark.browser`. They use Playwright to tes
Use `gh release create` to create releases. The tag is created automatically.
```bash
# IMPORTANT: Ensure you're on latest origin/main before releasing!
git fetch origin
git checkout origin/main
# Check current version
git tag --sort=-v:refname | head -1
@@ -134,7 +138,7 @@ CLI available as `cf` or `compose-farm`.
| `stop` | Stop services without removing containers (`docker compose stop`) |
| `pull` | Pull latest images |
| `restart` | `down` + `up -d` |
| `update` | `pull` + `build` + `down` + `up -d` |
| `update` | Pull, build, recreate only if changed (`up -d --pull always --build`) |
| `apply` | Make reality match config: migrate stacks + stop orphans. Use `--dry-run` to preview |
| `compose` | Run any docker compose command on a stack (passthrough) |
| `logs` | Show stack logs |

View File

@@ -370,7 +370,7 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
| `cf down <stack>` | Stop and remove stack containers |
| `cf stop <stack>` | Stop stack without removing containers |
| `cf restart <stack>` | down + up |
| `cf update <stack>` | pull + build + down + up |
| `cf update <stack>` | Pull, build, recreate only if changed |
| `cf pull <stack>` | Pull latest images |
| `cf logs -f <stack>` | Follow logs |
| `cf ps` | Show status of all stacks |
@@ -403,7 +403,7 @@ cf pull --all
# Restart (down + up)
cf restart plex
# Update (pull + build + down + up) - the end-to-end update command
# Update (pull + build, only recreates containers if images changed)
cf update --all
# Update state from reality (discovers running stacks + captures digests)
@@ -475,8 +475,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ pull Pull latest images (docker compose pull). │
│ restart Restart stacks (down + up). With --service, restarts just │
│ that service. │
│ update Update stacks (pull + build + down + up). With --service,
│ updates just that service. │
│ update Update stacks. Only recreates containers if images changed.
│ apply Make reality match config (start, migrate, stop │
│ strays/orphans as needed). │
│ compose Run any docker compose command on a stack. │
@@ -694,8 +693,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf update [OPTIONS] [STACKS]...
Update stacks (pull + build + down + up). With --service, updates just that
service.
Update stacks. Only recreates containers if images changed.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │

View File

@@ -15,7 +15,7 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias `
| | `down` | Stop stacks |
| | `stop` | Stop services without removing containers |
| | `restart` | Restart stacks (down + up) |
| | `update` | Update stacks (pull + build + down + up) |
| | `update` | Update stacks (only recreates if images changed) |
| | `pull` | Pull latest images |
| | `compose` | Run any docker compose command |
| **Monitoring** | `ps` | Show stack status |
@@ -225,7 +225,7 @@ cf restart immich --service database
### cf update
Update stacks (pull + build + down + up). With `--service`, updates just that service.
Update stacks. Only recreates containers if images changed. With `--service`, updates just that service.
<video autoplay loop muted playsinline>
<source src="/assets/update.webm" type="video/webm">

View File

@@ -1,5 +1,5 @@
# Update Demo
# Shows updating stacks (pull + build + down + up)
# Shows updating stacks (only recreates containers if images changed)
Output docs/assets/update.gif
Output docs/assets/update.webm

View File

@@ -329,7 +329,7 @@ cf apply
```bash
cf update --all
# Runs: pull + build + down + up for each stack
# Only recreates containers if images changed
```
## Next Steps

View File

@@ -1,7 +1,7 @@
"""Hatch build hook to vendor CDN assets for offline use.
During wheel builds, this hook:
1. Parses base.html to find elements with data-vendor attributes
1. Reads vendor-assets.json to find assets marked for vendoring
2. Downloads each CDN asset to a temporary vendor directory
3. Rewrites base.html to use local /static/vendor/ paths
4. Fetches and bundles license information
@@ -13,6 +13,7 @@ distributed wheel has vendored assets.
from __future__ import annotations
import json
import re
import shutil
import subprocess
@@ -23,22 +24,6 @@ from urllib.request import Request, urlopen
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
# Matches elements with data-vendor attribute: extracts URL and target filename
# Example: <script src="https://..." data-vendor="htmx.js">
# Captures: (1) src/href, (2) URL, (3) attributes between, (4) vendor filename
VENDOR_PATTERN = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
# License URLs for each package (GitHub raw URLs)
LICENSE_URLS: dict[str, tuple[str, str]] = {
"htmx": ("MIT", "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"),
"xterm": ("MIT", "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"),
"daisyui": ("MIT", "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"),
"tailwindcss": (
"MIT",
"https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE",
),
}
def _download(url: str) -> bytes:
"""Download a URL, trying urllib first then curl as fallback."""
@@ -61,7 +46,14 @@ def _download(url: str) -> bytes:
return bytes(result.stdout)
def _generate_licenses_file(temp_dir: Path) -> None:
def _load_vendor_assets(root: Path) -> dict[str, Any]:
"""Load vendor-assets.json from the web module."""
json_path = root / "src" / "compose_farm" / "web" / "vendor-assets.json"
with json_path.open() as f:
return json.load(f)
def _generate_licenses_file(temp_dir: Path, licenses: dict[str, dict[str, str]]) -> None:
"""Download and combine license files into LICENSES.txt."""
lines = [
"# Vendored Dependencies - License Information",
@@ -73,7 +65,9 @@ def _generate_licenses_file(temp_dir: Path) -> None:
"",
]
for pkg_name, (license_type, license_url) in LICENSE_URLS.items():
for pkg_name, license_info in licenses.items():
license_type = license_info["type"]
license_url = license_info["url"]
lines.append(f"## {pkg_name} ({license_type})")
lines.append(f"Source: {license_url}")
lines.append("")
@@ -107,44 +101,57 @@ class VendorAssetsHook(BuildHookInterface): # type: ignore[misc]
if not base_html_path.exists():
return
# Load vendor assets configuration
vendor_config = _load_vendor_assets(Path(self.root))
assets_to_vendor = vendor_config["assets"]
if not assets_to_vendor:
return
# Create temp directory for vendored assets
temp_dir = Path(tempfile.mkdtemp(prefix="compose_farm_vendor_"))
vendor_dir = temp_dir / "vendor"
vendor_dir.mkdir()
# Read and parse base.html
# Read base.html
html_content = base_html_path.read_text()
# Build URL to filename mapping and download assets
url_to_filename: dict[str, str] = {}
# Find all elements with data-vendor attribute and download them
for match in VENDOR_PATTERN.finditer(html_content):
url = match.group(2)
filename = match.group(4)
if url in url_to_filename:
continue
for asset in assets_to_vendor:
url = asset["url"]
filename = asset["filename"]
url_to_filename[url] = filename
filepath = vendor_dir / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
content = _download(url)
(vendor_dir / filename).write_bytes(content)
filepath.write_bytes(content)
if not url_to_filename:
return
# Generate LICENSES.txt from the JSON config
_generate_licenses_file(vendor_dir, vendor_config["licenses"])
# Generate LICENSES.txt
_generate_licenses_file(vendor_dir)
# Rewrite HTML: replace CDN URLs with local paths and remove data-vendor attributes
# Pattern matches: src="URL" ... data-vendor="filename" or href="URL" ... data-vendor="filename"
vendor_pattern = re.compile(r'(src|href)="(https://[^"]+)"([^>]*?)data-vendor="([^"]+)"')
# Rewrite HTML to use local paths (remove data-vendor, update URL)
def replace_vendor_tag(match: re.Match[str]) -> str:
attr = match.group(1) # src or href
url = match.group(2)
between = match.group(3) # attributes between URL and data-vendor
filename = match.group(4)
if url in url_to_filename:
filename = url_to_filename[url]
return f'{attr}="/static/vendor/{filename}"{between}'
return match.group(0)
modified_html = VENDOR_PATTERN.sub(replace_vendor_tag, html_content)
modified_html = vendor_pattern.sub(replace_vendor_tag, html_content)
# Inject vendored mode flag for JavaScript to detect
# Insert right after <head> tag so it's available early
modified_html = modified_html.replace(
"<head>",
"<head>\n <script>window.CF_VENDORED=true;</script>",
1, # Only replace first occurrence
)
# Write modified base.html to temp
templates_dir = temp_dir / "templates"

View File

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Barn with Container Doors -->
<defs>
<linearGradient id="barnRed" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#D94B4B"/>
<stop offset="100%" style="stop-color:#B33A3A"/>
</linearGradient>
<linearGradient id="roofRed" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#C44040"/>
<stop offset="100%" style="stop-color:#9E2E2E"/>
</linearGradient>
</defs>
<!-- Barn body -->
<rect x="40" y="90" width="120" height="80" fill="url(#barnRed)" rx="2"/>
<!-- Barn roof -->
<polygon points="30,90 100,40 170,90" fill="url(#roofRed)"/>
<!-- Roof cupola -->
<rect x="90" y="35" width="20" height="15" fill="#8B4513"/>
<polygon points="85,35 100,20 115,35" fill="#A0522D"/>
<!-- Container doors (left) -->
<rect x="48" y="105" width="45" height="58" fill="#2496ED" rx="2"/>
<rect x="50" y="107" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="50" y="121" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="50" y="135" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="50" y="149" width="41" height="12" fill="#1A7AC7" rx="1"/>
<!-- Container doors (right) -->
<rect x="107" y="105" width="45" height="58" fill="#2496ED" rx="2"/>
<rect x="109" y="107" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="109" y="121" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="109" y="135" width="41" height="12" fill="#1A7AC7" rx="1"/>
<rect x="109" y="149" width="41" height="12" fill="#1A7AC7" rx="1"/>
<!-- Door handles -->
<circle cx="90" cy="134" r="3" fill="#F0F0F0"/>
<circle cx="110" cy="134" r="3" fill="#F0F0F0"/>
<!-- Ground -->
<ellipse cx="100" cy="175" rx="70" ry="8" fill="#5D8A3E" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,72 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Container with Wheat Growing -->
<defs>
<linearGradient id="containerBlue" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="100%" style="stop-color:#1A7AC7"/>
</linearGradient>
<linearGradient id="wheat" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F4D03F"/>
<stop offset="100%" style="stop-color:#D4AC0D"/>
</linearGradient>
</defs>
<!-- Container base -->
<rect x="35" y="110" width="130" height="70" fill="url(#containerBlue)" rx="4"/>
<!-- Container ridges -->
<rect x="40" y="115" width="120" height="14" fill="#1A7AC7" rx="2"/>
<rect x="40" y="133" width="120" height="14" fill="#1A7AC7" rx="2"/>
<rect x="40" y="151" width="120" height="14" fill="#1A7AC7" rx="2"/>
<rect x="40" y="169" width="120" height="8" fill="#1A7AC7" rx="2"/>
<!-- Container corner posts -->
<rect x="35" y="110" width="8" height="70" fill="#1565C0"/>
<rect x="157" y="110" width="8" height="70" fill="#1565C0"/>
<!-- Wheat stalks -->
<g stroke="#8B7355" stroke-width="2" fill="none">
<path d="M70 110 Q68 80 70 50"/>
<path d="M100 110 Q100 70 100 35"/>
<path d="M130 110 Q132 80 130 50"/>
</g>
<!-- Wheat heads (left) -->
<g fill="url(#wheat)">
<ellipse cx="70" cy="50" rx="4" ry="8"/>
<ellipse cx="65" cy="55" rx="4" ry="7"/>
<ellipse cx="75" cy="55" rx="4" ry="7"/>
<ellipse cx="63" cy="62" rx="3" ry="6"/>
<ellipse cx="77" cy="62" rx="3" ry="6"/>
</g>
<!-- Wheat heads (center - taller) -->
<g fill="url(#wheat)">
<ellipse cx="100" cy="35" rx="5" ry="9"/>
<ellipse cx="94" cy="42" rx="4" ry="8"/>
<ellipse cx="106" cy="42" rx="4" ry="8"/>
<ellipse cx="91" cy="51" rx="4" ry="7"/>
<ellipse cx="109" cy="51" rx="4" ry="7"/>
<ellipse cx="94" cy="60" rx="3" ry="6"/>
<ellipse cx="106" cy="60" rx="3" ry="6"/>
</g>
<!-- Wheat heads (right) -->
<g fill="url(#wheat)">
<ellipse cx="130" cy="50" rx="4" ry="8"/>
<ellipse cx="125" cy="55" rx="4" ry="7"/>
<ellipse cx="135" cy="55" rx="4" ry="7"/>
<ellipse cx="123" cy="62" rx="3" ry="6"/>
<ellipse cx="137" cy="62" rx="3" ry="6"/>
</g>
<!-- Small leaves on stalks -->
<g fill="#6B8E23">
<path d="M70 85 Q60 80 70 75"/>
<path d="M70 75 Q80 70 70 65"/>
<path d="M100 90 Q88 85 100 80"/>
<path d="M100 75 Q112 70 100 65"/>
<path d="M130 85 Q140 80 130 75"/>
<path d="M130 75 Q120 70 130 65"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Silos as Stacked Containers -->
<defs>
<linearGradient id="silo1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="50%" style="stop-color:#4AA8F2"/>
<stop offset="100%" style="stop-color:#2496ED"/>
</linearGradient>
<linearGradient id="silo2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#1E88E5"/>
<stop offset="50%" style="stop-color:#42A5F5"/>
<stop offset="100%" style="stop-color:#1E88E5"/>
</linearGradient>
<linearGradient id="silo3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#1976D2"/>
<stop offset="50%" style="stop-color:#2196F3"/>
<stop offset="100%" style="stop-color:#1976D2"/>
</linearGradient>
</defs>
<!-- Left silo (shortest) -->
<rect x="25" y="100" width="40" height="75" fill="url(#silo1)" rx="3"/>
<ellipse cx="45" cy="100" rx="20" ry="8" fill="#4AA8F2"/>
<ellipse cx="45" cy="175" rx="20" ry="5" fill="#1565C0"/>
<!-- Container ridges -->
<path d="M25 115 h40 M25 130 h40 M25 145 h40 M25 160 h40" stroke="#1A7AC7" stroke-width="2"/>
<!-- Roof dome -->
<ellipse cx="45" cy="100" rx="18" ry="12" fill="#1565C0"/>
<ellipse cx="45" cy="98" rx="16" ry="10" fill="#2196F3"/>
<!-- Middle silo (tallest) -->
<rect x="80" y="55" width="45" height="120" fill="url(#silo2)" rx="3"/>
<ellipse cx="102.5" cy="55" rx="22.5" ry="10" fill="#42A5F5"/>
<ellipse cx="102.5" cy="175" rx="22.5" ry="5" fill="#1565C0"/>
<!-- Container ridges -->
<path d="M80 75 h45 M80 95 h45 M80 115 h45 M80 135 h45 M80 155 h45" stroke="#1976D2" stroke-width="2"/>
<!-- Roof dome -->
<ellipse cx="102.5" cy="55" rx="20" ry="14" fill="#1565C0"/>
<ellipse cx="102.5" cy="52" rx="18" ry="12" fill="#2196F3"/>
<!-- Cupola -->
<rect x="97" y="35" width="11" height="17" fill="#1565C0"/>
<polygon points="92,35 102.5,22 113,35" fill="#1976D2"/>
<!-- Right silo (medium) -->
<rect x="140" y="85" width="38" height="90" fill="url(#silo3)" rx="3"/>
<ellipse cx="159" cy="85" rx="19" ry="8" fill="#2196F3"/>
<ellipse cx="159" cy="175" rx="19" ry="5" fill="#1565C0"/>
<!-- Container ridges -->
<path d="M140 100 h38 M140 115 h38 M140 130 h38 M140 145 h38 M140 160 h38" stroke="#1565C0" stroke-width="2"/>
<!-- Roof dome -->
<ellipse cx="159" cy="85" rx="17" ry="11" fill="#1565C0"/>
<ellipse cx="159" cy="83" rx="15" ry="9" fill="#2196F3"/>
<!-- Ground -->
<ellipse cx="100" cy="180" rx="85" ry="10" fill="#5D8A3E" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Minimalist Tractor with Container Trailer -->
<defs>
<linearGradient id="tractorGreen" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50"/>
<stop offset="100%" style="stop-color:#388E3C"/>
</linearGradient>
<linearGradient id="containerBlue2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="100%" style="stop-color:#1976D2"/>
</linearGradient>
</defs>
<!-- Container trailer -->
<rect x="90" y="95" width="85" height="50" fill="url(#containerBlue2)" rx="3"/>
<!-- Container ridges -->
<rect x="95" y="100" width="75" height="10" fill="#1A7AC7" rx="1"/>
<rect x="95" y="113" width="75" height="10" fill="#1A7AC7" rx="1"/>
<rect x="95" y="126" width="75" height="10" fill="#1A7AC7" rx="1"/>
<!-- Corner posts -->
<rect x="90" y="95" width="6" height="50" fill="#1565C0"/>
<rect x="169" y="95" width="6" height="50" fill="#1565C0"/>
<!-- Trailer wheels -->
<circle cx="115" cy="155" r="12" fill="#333"/>
<circle cx="115" cy="155" r="6" fill="#666"/>
<circle cx="150" cy="155" r="12" fill="#333"/>
<circle cx="150" cy="155" r="6" fill="#666"/>
<!-- Trailer hitch bar -->
<rect x="55" y="125" width="40" height="6" fill="#555"/>
<!-- Tractor body -->
<rect x="20" y="90" width="50" height="40" fill="url(#tractorGreen)" rx="5"/>
<!-- Tractor hood -->
<rect x="15" y="100" width="20" height="25" fill="url(#tractorGreen)" rx="3"/>
<!-- Tractor cabin -->
<rect x="35" y="70" width="30" height="25" fill="#333" rx="3"/>
<rect x="38" y="73" width="24" height="18" fill="#87CEEB" rx="2"/>
<!-- Exhaust pipe -->
<rect x="18" y="80" width="5" height="20" fill="#444" rx="1"/>
<!-- Big rear wheel -->
<circle cx="55" cy="145" r="22" fill="#333"/>
<circle cx="55" cy="145" r="16" fill="#444"/>
<circle cx="55" cy="145" r="8" fill="#666"/>
<!-- Wheel treads -->
<g stroke="#555" stroke-width="3">
<line x1="55" y1="125" x2="55" y2="130"/>
<line x1="55" y1="160" x2="55" y2="165"/>
<line x1="35" y1="145" x2="40" y2="145"/>
<line x1="70" y1="145" x2="75" y2="145"/>
<line x1="40" y1="130" x2="43" y2="133"/>
<line x1="67" y1="157" x2="70" y2="160"/>
<line x1="40" y1="160" x2="43" y2="157"/>
<line x1="67" y1="133" x2="70" y2="130"/>
</g>
<!-- Small front wheel -->
<circle cx="22" cy="145" r="12" fill="#333"/>
<circle cx="22" cy="145" r="8" fill="#444"/>
<circle cx="22" cy="145" r="4" fill="#666"/>
<!-- Ground line -->
<line x1="5" y1="167" x2="195" y2="167" stroke="#5D8A3E" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Harvest Crate / Container Hybrid -->
<defs>
<linearGradient id="wood" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DEB887"/>
<stop offset="100%" style="stop-color:#B8860B"/>
</linearGradient>
<linearGradient id="blueAccent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="100%" style="stop-color:#1976D2"/>
</linearGradient>
</defs>
<!-- Crate back panel (3D effect) -->
<polygon points="45,60 155,60 175,45 65,45" fill="#A0522D"/>
<polygon points="155,60 155,150 175,135 175,45" fill="#8B4513"/>
<!-- Main crate body -->
<rect x="45" y="60" width="110" height="90" fill="url(#wood)" rx="3"/>
<!-- Horizontal slats -->
<rect x="45" y="60" width="110" height="18" fill="#C9A86C" rx="2"/>
<rect x="45" y="82" width="110" height="18" fill="#C9A86C" rx="2"/>
<rect x="45" y="104" width="110" height="18" fill="#C9A86C" rx="2"/>
<rect x="45" y="126" width="110" height="18" fill="#C9A86C" rx="2"/>
<!-- Slat gaps (darker lines) -->
<line x1="45" y1="78" x2="155" y2="78" stroke="#8B7355" stroke-width="2"/>
<line x1="45" y1="100" x2="155" y2="100" stroke="#8B7355" stroke-width="2"/>
<line x1="45" y1="122" x2="155" y2="122" stroke="#8B7355" stroke-width="2"/>
<line x1="45" y1="144" x2="155" y2="144" stroke="#8B7355" stroke-width="2"/>
<!-- Blue container-style corner brackets -->
<path d="M45 60 L45 90 L55 90 L55 70 L75 70 L75 60 Z" fill="url(#blueAccent)"/>
<path d="M155 60 L155 90 L145 90 L145 70 L125 70 L125 60 Z" fill="url(#blueAccent)"/>
<path d="M45 150 L45 120 L55 120 L55 140 L75 140 L75 150 Z" fill="url(#blueAccent)"/>
<path d="M155 150 L155 120 L145 120 L145 140 L125 140 L125 150 Z" fill="url(#blueAccent)"/>
<!-- Docker/container symbol in center -->
<g transform="translate(100, 105)">
<rect x="-25" y="-15" width="50" height="30" fill="#2496ED" rx="3" opacity="0.9"/>
<!-- Container boxes inside -->
<rect x="-22" y="-12" width="12" height="8" fill="#fff" rx="1"/>
<rect x="-7" y="-12" width="12" height="8" fill="#fff" rx="1"/>
<rect x="8" y="-12" width="12" height="8" fill="#fff" rx="1"/>
<rect x="-22" y="0" width="12" height="8" fill="#fff" rx="1"/>
<rect x="-7" y="0" width="12" height="8" fill="#fff" rx="1"/>
<rect x="8" y="0" width="12" height="8" fill="#fff" rx="1"/>
</g>
<!-- Rope handles -->
<ellipse cx="35" cy="100" rx="8" ry="15" fill="none" stroke="#8B7355" stroke-width="4"/>
<ellipse cx="165" cy="100" rx="8" ry="15" fill="none" stroke="#8B7355" stroke-width="4"/>
<!-- Ground shadow -->
<ellipse cx="100" cy="160" rx="60" ry="8" fill="#333" opacity="0.2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Minimal Icon: Container with sprouting plant -->
<defs>
<linearGradient id="blueGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="100%" style="stop-color:#1565C0"/>
</linearGradient>
<linearGradient id="greenGrad" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#4CAF50"/>
<stop offset="100%" style="stop-color:#81C784"/>
</linearGradient>
</defs>
<!-- Container -->
<rect x="40" y="100" width="120" height="70" fill="url(#blueGrad)" rx="6"/>
<!-- Container ridges -->
<g fill="#1A7AC7">
<rect x="48" y="108" width="104" height="12" rx="2"/>
<rect x="48" y="124" width="104" height="12" rx="2"/>
<rect x="48" y="140" width="104" height="12" rx="2"/>
<rect x="48" y="156" width="104" height="10" rx="2"/>
</g>
<!-- Corner posts -->
<rect x="40" y="100" width="10" height="70" fill="#0D47A1" rx="2"/>
<rect x="150" y="100" width="10" height="70" fill="#0D47A1" rx="2"/>
<!-- Plant stem -->
<path d="M100 100 Q100 75 100 50" stroke="#4CAF50" stroke-width="6" fill="none" stroke-linecap="round"/>
<!-- Leaves -->
<ellipse cx="85" cy="65" rx="15" ry="8" fill="url(#greenGrad)" transform="rotate(-30 85 65)"/>
<ellipse cx="115" cy="65" rx="15" ry="8" fill="url(#greenGrad)" transform="rotate(30 115 65)"/>
<ellipse cx="80" cy="80" rx="12" ry="6" fill="url(#greenGrad)" transform="rotate(-45 80 80)"/>
<ellipse cx="120" cy="80" rx="12" ry="6" fill="url(#greenGrad)" transform="rotate(45 120 80)"/>
<!-- Top leaves (crown) -->
<ellipse cx="100" cy="45" rx="10" ry="18" fill="url(#greenGrad)"/>
<ellipse cx="90" cy="50" rx="8" ry="14" fill="#66BB6A" transform="rotate(-20 90 50)"/>
<ellipse cx="110" cy="50" rx="8" ry="14" fill="#66BB6A" transform="rotate(20 110 50)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Hexagon grid: containers as honeycomb/farm cells -->
<defs>
<linearGradient id="hex1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2496ED"/>
<stop offset="100%" style="stop-color:#1976D2"/>
</linearGradient>
<linearGradient id="hex2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4CAF50"/>
<stop offset="100%" style="stop-color:#388E3C"/>
</linearGradient>
</defs>
<!-- Hexagon cluster -->
<g transform="translate(100, 100)">
<!-- Center hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)"/>
<!-- Top hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)" transform="translate(0, -52)" opacity="0.8"/>
<!-- Top-right hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)" transform="translate(45, -26)" opacity="0.6"/>
<!-- Bottom-right hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)" transform="translate(45, 26)" opacity="0.7"/>
<!-- Bottom hex - green accent -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex2)" transform="translate(0, 52)"/>
<!-- Bottom-left hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)" transform="translate(-45, 26)" opacity="0.7"/>
<!-- Top-left hex -->
<polygon points="0,-30 26,-15 26,15 0,30 -26,15 -26,-15" fill="url(#hex1)" transform="translate(-45, -26)" opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Minimal: geometric container with abstract sprout -->
<!-- Container base - single rounded rectangle -->
<rect x="40" y="95" width="120" height="75" rx="8" fill="#2496ED"/>
<!-- Simple horizontal lines -->
<line x1="50" y1="115" x2="150" y2="115" stroke="#1565C0" stroke-width="3" stroke-linecap="round"/>
<line x1="50" y1="135" x2="150" y2="135" stroke="#1565C0" stroke-width="3" stroke-linecap="round"/>
<line x1="50" y1="155" x2="150" y2="155" stroke="#1565C0" stroke-width="3" stroke-linecap="round"/>
<!-- Abstract sprout - two simple leaves -->
<path d="M100 95 L100 55" stroke="#4CAF50" stroke-width="4" stroke-linecap="round"/>
<ellipse cx="85" cy="55" rx="18" ry="10" fill="#4CAF50" transform="rotate(-35 85 55)"/>
<ellipse cx="115" cy="55" rx="18" ry="10" fill="#4CAF50" transform="rotate(35 115 55)"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Stacked containers with upward growth arrow -->
<!-- Bottom container -->
<rect x="50" y="130" width="100" height="35" rx="4" fill="#2496ED"/>
<!-- Middle container -->
<rect x="60" y="90" width="80" height="35" rx="4" fill="#42A5F5"/>
<!-- Top container -->
<rect x="70" y="50" width="60" height="35" rx="4" fill="#64B5F6"/>
<!-- Growth arrow emerging from top -->
<path d="M100 50 L100 25" stroke="#4CAF50" stroke-width="6" stroke-linecap="round"/>
<polygon points="100,15 90,28 110,28" fill="#4CAF50"/>
</svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Negative space: container with plant cutout -->
<defs>
<mask id="plantMask">
<rect width="200" height="200" fill="white"/>
<!-- Stem cutout -->
<rect x="96" y="40" width="8" height="55" fill="black"/>
<!-- Leaf cutouts -->
<ellipse cx="82" cy="55" rx="16" ry="9" fill="black" transform="rotate(-40 82 55)"/>
<ellipse cx="118" cy="55" rx="16" ry="9" fill="black" transform="rotate(40 118 55)"/>
<ellipse cx="100" cy="38" rx="10" ry="14" fill="black"/>
</mask>
</defs>
<!-- Container with mask -->
<g mask="url(#plantMask)">
<rect x="35" y="45" width="130" height="120" rx="12" fill="#2496ED"/>
<!-- Ridges -->
<rect x="35" y="70" width="130" height="4" fill="#1976D2"/>
<rect x="35" y="95" width="130" height="4" fill="#1976D2"/>
<rect x="35" y="120" width="130" height="4" fill="#1976D2"/>
<rect x="35" y="145" width="130" height="4" fill="#1976D2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1018 B

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- CF Monogram with container lines -->
<!-- C shape -->
<path d="M45 100
C45 55, 85 35, 115 45
L108 62
C88 55, 62 70, 62 100
C62 130, 88 145, 108 138
L115 155
C85 165, 45 145, 45 100"
fill="#2496ED"/>
<!-- F shape integrated with container lines -->
<rect x="110" y="45" width="50" height="14" rx="2" fill="#2496ED"/>
<rect x="110" y="45" width="14" height="110" rx="2" fill="#2496ED"/>
<rect x="110" y="90" width="40" height="12" rx="2" fill="#2496ED"/>
<!-- Container ridge accents on F -->
<rect x="128" y="63" width="32" height="3" fill="#4CAF50"/>
<rect x="128" y="73" width="32" height="3" fill="#4CAF50"/>
<rect x="128" y="108" width="22" height="3" fill="#4CAF50"/>
<rect x="128" y="118" width="22" height="3" fill="#4CAF50"/>
</svg>

After

Width:  |  Height:  |  Size: 909 B

View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Ultra-minimal barn silhouette -->
<!-- Barn shape - single path -->
<path d="M35 170
L35 85
L100 40
L165 85
L165 170
Z"
fill="#2496ED"/>
<!-- Container door accent - simple rectangle grid -->
<g fill="#fff" opacity="0.95">
<rect x="55" y="100" width="35" height="12" rx="2"/>
<rect x="55" y="118" width="35" height="12" rx="2"/>
<rect x="55" y="136" width="35" height="12" rx="2"/>
<rect x="55" y="154" width="35" height="14" rx="2"/>
<rect x="110" y="100" width="35" height="12" rx="2"/>
<rect x="110" y="118" width="35" height="12" rx="2"/>
<rect x="110" y="136" width="35" height="12" rx="2"/>
<rect x="110" y="154" width="35" height="14" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Container grid made of dots - represents distributed containers -->
<g fill="#2496ED">
<!-- Row 1 -->
<circle cx="60" cy="50" r="12"/>
<circle cx="100" cy="50" r="12"/>
<circle cx="140" cy="50" r="12"/>
<!-- Row 2 -->
<circle cx="60" cy="90" r="12"/>
<circle cx="100" cy="90" r="12"/>
<circle cx="140" cy="90" r="12"/>
<!-- Row 3 -->
<circle cx="60" cy="130" r="12"/>
<circle cx="100" cy="130" r="12" fill="#4CAF50"/>
<circle cx="140" cy="130" r="12"/>
</g>
<!-- Connection lines -->
<g stroke="#2496ED" stroke-width="2" opacity="0.4">
<line x1="60" y1="62" x2="60" y2="78"/>
<line x1="100" y1="62" x2="100" y2="78"/>
<line x1="140" y1="62" x2="140" y2="78"/>
<line x1="60" y1="102" x2="60" y2="118"/>
<line x1="100" y1="102" x2="100" y2="118"/>
<line x1="140" y1="102" x2="140" y2="118"/>
<line x1="72" y1="50" x2="88" y2="50"/>
<line x1="112" y1="50" x2="128" y2="50"/>
<line x1="72" y1="90" x2="88" y2="90"/>
<line x1="112" y1="90" x2="128" y2="90"/>
<line x1="72" y1="130" x2="88" y2="130"/>
<line x1="112" y1="130" x2="128" y2="130"/>
</g>
<!-- Sprout from green center -->
<path d="M100 118 L100 155" stroke="#4CAF50" stroke-width="3" stroke-linecap="round"/>
<circle cx="92" cy="158" r="6" fill="#4CAF50"/>
<circle cx="108" cy="158" r="6" fill="#4CAF50"/>
<circle cx="100" cy="165" r="5" fill="#66BB6A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Single bold mark: container + leaf combined -->
<!-- Main container shape -->
<rect x="40" y="60" width="120" height="100" rx="10" fill="#2496ED"/>
<!-- Leaf growing from top-right corner -->
<path d="M140 60
Q170 30, 160 55
Q175 25, 145 50
Q165 15, 130 50
L140 60"
fill="#4CAF50"/>
<!-- Minimal internal lines -->
<line x1="55" y1="95" x2="145" y2="95" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<line x1="55" y1="125" x2="145" y2="125" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

View File

@@ -0,0 +1,136 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1052 523">
<defs>
<!-- Container blue colors -->
<linearGradient id="containerFront" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#4AA8D8"/>
<stop offset="100%" style="stop-color:#5BB5E0"/>
</linearGradient>
<linearGradient id="containerSide" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3A8AB8"/>
<stop offset="100%" style="stop-color:#4AA8D8"/>
</linearGradient>
<linearGradient id="containerTop" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#6BC4E8"/>
<stop offset="100%" style="stop-color:#7DD0F0"/>
</linearGradient>
<linearGradient id="roofGrad" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#4AA8D8"/>
<stop offset="100%" style="stop-color:#6BC4E8"/>
</linearGradient>
</defs>
<!-- Green farm field -->
<path d="M526 420 L120 280 L526 200 L932 280 Z" fill="#7CB342"/>
<path d="M526 420 L526 200" stroke="#8BC34A" stroke-width="2"/>
<path d="M526 420 L220 265" stroke="#8BC34A" stroke-width="2"/>
<path d="M526 420 L832 265" stroke="#8BC34A" stroke-width="2"/>
<path d="M526 420 L320 250" stroke="#8BC34A" stroke-width="2"/>
<path d="M526 420 L732 250" stroke="#8BC34A" stroke-width="2"/>
<!-- Field edge -->
<ellipse cx="526" cy="420" rx="410" ry="30" fill="#5D8C2A"/>
<!-- Left container stack -->
<g transform="translate(95, 180)">
<!-- Back container -->
<polygon points="60,40 130,10 130,70 60,100" fill="#3A8AB8"/>
<polygon points="0,70 60,40 60,100 0,130" fill="url(#containerFront)"/>
<polygon points="0,70 60,40 130,70 70,100" fill="url(#containerTop)"/>
<!-- Container ridges -->
<line x1="5" y1="80" x2="55" y2="52" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="95" x2="55" y2="67" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="110" x2="55" y2="82" stroke="#2D7A9E" stroke-width="2"/>
<!-- Front container -->
<polygon points="60,80 130,50 130,110 60,140" fill="#3A8AB8"/>
<polygon points="0,110 60,80 60,140 0,170" fill="url(#containerFront)"/>
<polygon points="0,110 60,80 130,110 70,140" fill="url(#containerTop)"/>
<!-- Container ridges -->
<line x1="5" y1="120" x2="55" y2="92" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="135" x2="55" y2="107" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="150" x2="55" y2="122" stroke="#2D7A9E" stroke-width="2"/>
</g>
<!-- Right container stack -->
<g transform="translate(827, 180)">
<!-- Back container -->
<polygon points="0,10 70,40 70,100 0,70" fill="url(#containerFront)"/>
<polygon points="70,40 130,70 130,130 70,100" fill="#3A8AB8"/>
<polygon points="0,10 70,40 130,10 60,0" fill="url(#containerTop)"/>
<!-- Container ridges -->
<line x1="5" y1="20" x2="65" y2="48" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="35" x2="65" y2="63" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="50" x2="65" y2="78" stroke="#2D7A9E" stroke-width="2"/>
<!-- Front container -->
<polygon points="0,50 70,80 70,140 0,110" fill="url(#containerFront)"/>
<polygon points="70,80 130,110 130,170 70,140" fill="#3A8AB8"/>
<polygon points="0,50 70,80 130,50 60,20" fill="url(#containerTop)"/>
<!-- Container ridges -->
<line x1="5" y1="60" x2="65" y2="88" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="75" x2="65" y2="103" stroke="#2D7A9E" stroke-width="2"/>
<line x1="5" y1="90" x2="65" y2="118" stroke="#2D7A9E" stroke-width="2"/>
</g>
<!-- Dashed connection lines -->
<g stroke="#2D3E50" stroke-width="3" stroke-dasharray="8,6" fill="none">
<path d="M225 320 Q350 280 430 300"/>
<path d="M827 320 Q700 280 620 300"/>
<path d="M225 350 Q300 340 380 360"/>
<path d="M827 350 Q750 340 670 360"/>
</g>
<!-- Central barn/container structure -->
<g transform="translate(400, 85)">
<!-- Base left container -->
<polygon points="0,220 126,155 126,295 0,360" fill="url(#containerFront)"/>
<!-- Container ridges -->
<line x1="10" y1="232" x2="116" y2="172" stroke="#2D7A9E" stroke-width="3"/>
<line x1="10" y1="262" x2="116" y2="202" stroke="#2D7A9E" stroke-width="3"/>
<line x1="10" y1="292" x2="116" y2="232" stroke="#2D7A9E" stroke-width="3"/>
<line x1="10" y1="322" x2="116" y2="262" stroke="#2D7A9E" stroke-width="3"/>
<!-- Base right container -->
<polygon points="126,155 252,220 252,360 126,295" fill="#3A8AB8"/>
<!-- Container ridges -->
<line x1="136" y1="172" x2="242" y2="232" stroke="#2D7A9E" stroke-width="3"/>
<line x1="136" y1="202" x2="242" y2="262" stroke="#2D7A9E" stroke-width="3"/>
<line x1="136" y1="232" x2="242" y2="292" stroke="#2D7A9E" stroke-width="3"/>
<line x1="136" y1="262" x2="242" y2="322" stroke="#2D7A9E" stroke-width="3"/>
<!-- Door opening -->
<polygon points="80,260 126,235 172,260 172,360 126,335 80,360" fill="#1A2530"/>
<!-- Upper left container -->
<polygon points="20,135 126,75 126,155 20,215" fill="url(#containerFront)"/>
<line x1="28" y1="148" x2="118" y2="92" stroke="#2D7A9E" stroke-width="3"/>
<line x1="28" y1="173" x2="118" y2="117" stroke="#2D7A9E" stroke-width="3"/>
<!-- Upper right container -->
<polygon points="126,75 232,135 232,215 126,155" fill="#3A8AB8"/>
<line x1="134" y1="92" x2="224" y2="148" stroke="#2D7A9E" stroke-width="3"/>
<line x1="134" y1="117" x2="224" y2="173" stroke="#2D7A9E" stroke-width="3"/>
<!-- Roof -->
<polygon points="126,0 20,70 20,135 126,75" fill="url(#roofGrad)"/>
<polygon points="126,0 232,70 232,135 126,75" fill="#4AA8D8"/>
<polygon points="126,0 20,70 126,75 232,70" fill="#7DD0F0"/>
<!-- Roof ridges -->
<line x1="30" y1="80" x2="118" y2="35" stroke="#2D7A9E" stroke-width="2"/>
<line x1="30" y1="100" x2="118" y2="55" stroke="#2D7A9E" stroke-width="2"/>
<line x1="134" y1="35" x2="222" y2="80" stroke="#2D7A9E" stroke-width="2"/>
<line x1="134" y1="55" x2="222" y2="100" stroke="#2D7A9E" stroke-width="2"/>
<!-- Weather vane -->
<line x1="126" y1="0" x2="126" y2="-35" stroke="#2D3E50" stroke-width="3"/>
<!-- Crossbar -->
<line x1="100" y1="-25" x2="152" y2="-25" stroke="#2D3E50" stroke-width="3"/>
<!-- Terminal arrows -->
<polygon points="100,-25 108,-20 108,-30" fill="#2D3E50"/>
<polygon points="152,-25 144,-20 144,-30" fill="#2D3E50"/>
<!-- Top arrow -->
<polygon points="126,-35 120,-28 132,-28" fill="#2D3E50"/>
</g>
<!-- COMPOSE FARM text -->
<text x="526" y="500" text-anchor="middle" font-family="Arial Black, Arial, sans-serif" font-size="72" font-weight="900" fill="#2D3E50" letter-spacing="4">COMPOSE FARM</text>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<title>Compose Farm - SSH keyed access</title>
<defs>
<linearGradient id="cfBlue16" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2496ED"/>
<stop offset="100%" stop-color="#1565C0"/>
</linearGradient>
<linearGradient id="cfGreen16" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#66BB6A"/>
<stop offset="100%" stop-color="#2E7D32"/>
</linearGradient>
</defs>
<!-- Container -->
<g>
<rect x="70" y="70" width="115" height="90" rx="10" fill="url(#cfBlue16)"/>
<rect x="70" y="70" width="14" height="90" rx="6" fill="#0D47A1" opacity="0.9"/>
<rect x="171" y="70" width="14" height="90" rx="6" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="88" y="86" width="78" height="12" rx="4"/>
<rect x="88" y="104" width="78" height="12" rx="4"/>
<rect x="88" y="122" width="78" height="12" rx="4"/>
<rect x="88" y="140" width="78" height="12" rx="4"/>
</g>
</g>
<!-- SSH key -->
<g>
<circle cx="38" cy="118" r="18" fill="url(#cfGreen16)"/>
<circle cx="38" cy="118" r="7" fill="#E8F5E9" opacity="0.95"/>
<rect x="54" y="110" width="92" height="16" rx="8" fill="url(#cfGreen16)"/>
<rect x="112" y="126" width="12" height="16" rx="3" fill="#2E7D32"/>
<rect x="130" y="126" width="12" height="24" rx="3" fill="#2E7D32"/>
</g>
<!-- Key insertion hint -->
<path d="M62 118 L78 118" stroke="#E8F5E9" stroke-width="4" stroke-linecap="round" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<title>Compose Farm - reconcile loop</title>
<defs>
<linearGradient id="cfBlue17" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2496ED"/>
<stop offset="100%" stop-color="#1565C0"/>
</linearGradient>
<linearGradient id="cfGreen17" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#66BB6A"/>
<stop offset="100%" stop-color="#2E7D32"/>
</linearGradient>
<marker id="arrowGreen17" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0 L10 5 L0 10 Z" fill="#2E7D32"/>
</marker>
<marker id="arrowBlue17" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0 L10 5 L0 10 Z" fill="#1565C0"/>
</marker>
</defs>
<!-- Reconcile arrows -->
<g fill="none" stroke-linecap="round" opacity="0.9">
<path d="M46 112 A70 70 0 0 1 112 40" stroke="url(#cfGreen17)" stroke-width="6" marker-end="url(#arrowGreen17)"/>
<path d="M154 88 A70 70 0 0 1 88 160" stroke="url(#cfBlue17)" stroke-width="6" marker-end="url(#arrowBlue17)"/>
</g>
<!-- Central container -->
<g transform="translate(60,78)">
<rect x="0" y="0" width="80" height="52" rx="8" fill="url(#cfBlue17)"/>
<rect x="0" y="0" width="10" height="52" rx="4" fill="#0D47A1" opacity="0.9"/>
<rect x="70" y="0" width="10" height="52" rx="4" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="12" y="10" width="56" height="9" rx="3"/>
<rect x="12" y="22" width="56" height="9" rx="3"/>
<rect x="12" y="34" width="56" height="9" rx="3"/>
</g>
</g>
<!-- Apply check -->
<path d="M84 105 L96 117 L118 93" fill="none" stroke="#E8F5E9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<title>Compose Farm - auto migration</title>
<defs>
<linearGradient id="cfBlue18" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2496ED"/>
<stop offset="100%" stop-color="#1565C0"/>
</linearGradient>
<linearGradient id="cfGreen18" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#66BB6A"/>
<stop offset="100%" stop-color="#2E7D32"/>
</linearGradient>
<marker id="arrow18" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0 L10 5 L0 10 Z" fill="#2E7D32"/>
</marker>
</defs>
<!-- Hosts -->
<g fill="#334155" opacity="0.9">
<rect x="20" y="55" width="48" height="90" rx="8"/>
<rect x="132" y="55" width="48" height="90" rx="8"/>
</g>
<g fill="#E2E8F0" opacity="0.85">
<rect x="30" y="68" width="28" height="10" rx="3"/>
<rect x="30" y="86" width="28" height="10" rx="3"/>
<rect x="30" y="104" width="28" height="10" rx="3"/>
<circle cx="56" cy="130" r="4"/>
<rect x="142" y="68" width="28" height="10" rx="3"/>
<rect x="142" y="86" width="28" height="10" rx="3"/>
<rect x="142" y="104" width="28" height="10" rx="3"/>
<circle cx="168" cy="130" r="4"/>
</g>
<!-- Migration arrow -->
<path d="M72 100 L128 100" stroke="url(#cfGreen18)" stroke-width="6" stroke-linecap="round" marker-end="url(#arrow18)"/>
<!-- Moving container -->
<g transform="translate(88,86)">
<rect x="0" y="0" width="28" height="20" rx="4" fill="url(#cfBlue18)"/>
<rect x="0" y="0" width="5" height="20" rx="3" fill="#0D47A1" opacity="0.9"/>
<rect x="23" y="0" width="5" height="20" rx="3" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="6" y="5" width="16" height="4" rx="2"/>
<rect x="6" y="11" width="16" height="4" rx="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<title>Compose Farm - multi-host compose</title>
<defs>
<linearGradient id="cfBlue15" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2496ED"/>
<stop offset="100%" stop-color="#1565C0"/>
</linearGradient>
<linearGradient id="cfGreen15" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#81C784"/>
<stop offset="100%" stop-color="#2E7D32"/>
</linearGradient>
</defs>
<!-- Field -->
<polygon points="16,170 184,170 150,108 50,108" fill="url(#cfGreen15)" opacity="0.35"/>
<g stroke="#2E7D32" stroke-width="2" opacity="0.22" stroke-linecap="round">
<line x1="34" y1="170" x2="62" y2="108"/>
<line x1="70" y1="170" x2="86" y2="108"/>
<line x1="100" y1="170" x2="100" y2="108"/>
<line x1="130" y1="170" x2="114" y2="108"/>
<line x1="166" y1="170" x2="138" y2="108"/>
</g>
<!-- SSH paths -->
<g stroke="#334155" stroke-width="3" stroke-dasharray="4 6" opacity="0.75" fill="none" stroke-linecap="round">
<path d="M100 132 C82 118 68 114 46 118"/>
<path d="M100 132 C118 118 132 114 154 118"/>
</g>
<g fill="#334155" opacity="0.75">
<circle cx="100" cy="132" r="3"/>
<circle cx="46" cy="118" r="3"/>
<circle cx="154" cy="118" r="3"/>
</g>
<!-- Remote container: left -->
<g transform="translate(18,118)">
<rect x="0" y="0" width="56" height="34" rx="5" fill="url(#cfBlue15)"/>
<rect x="0" y="0" width="7" height="34" rx="3" fill="#0D47A1" opacity="0.9"/>
<rect x="49" y="0" width="7" height="34" rx="3" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="9" y="7" width="38" height="6" rx="2"/>
<rect x="9" y="15" width="38" height="6" rx="2"/>
<rect x="9" y="23" width="38" height="6" rx="2"/>
</g>
</g>
<!-- Remote container: right -->
<g transform="translate(126,118)">
<rect x="0" y="0" width="56" height="34" rx="5" fill="url(#cfBlue15)"/>
<rect x="0" y="0" width="7" height="34" rx="3" fill="#0D47A1" opacity="0.9"/>
<rect x="49" y="0" width="7" height="34" rx="3" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="9" y="7" width="38" height="6" rx="2"/>
<rect x="9" y="15" width="38" height="6" rx="2"/>
<rect x="9" y="23" width="38" height="6" rx="2"/>
</g>
</g>
<!-- Central container with prompt -->
<g transform="translate(58,80)">
<rect x="0" y="0" width="84" height="52" rx="7" fill="url(#cfBlue15)"/>
<rect x="0" y="0" width="10" height="52" rx="4" fill="#0D47A1" opacity="0.9"/>
<rect x="74" y="0" width="10" height="52" rx="4" fill="#0D47A1" opacity="0.9"/>
<g fill="#1A7AC7" opacity="0.9">
<rect x="12" y="10" width="60" height="9" rx="3"/>
<rect x="12" y="22" width="60" height="9" rx="3"/>
<rect x="12" y="34" width="60" height="9" rx="3"/>
</g>
<!-- Prompt: >_ -->
<g stroke="#F8FAFC" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.95">
<polyline points="18,18 30,26 18,34"/>
<line x1="36" y1="34" x2="58" y2="34"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -184,34 +184,17 @@ def update(
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
"""Update stacks. Only recreates containers if images changed."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
if service:
if len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
# For service-level update: pull + build + stop + up (stop instead of down)
raw = True
results = run_async(
run_sequential_on_stacks(
cfg,
stack_list,
[
f"pull --ignore-buildable {service}",
f"build {service}",
f"stop {service}",
f"up -d {service}",
],
raw=raw,
)
)
cmd = f"up -d --pull always --build {service}"
else:
raw = len(stack_list) == 1
results = run_async(
run_sequential_on_stacks(
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
)
)
cmd = "up -d --pull always --build"
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
maybe_regenerate_traefik(cfg, results)
report_results(results)

View File

@@ -1,78 +1,39 @@
"""CDN asset definitions and caching for tests and demo recordings.
This module provides a single source of truth for CDN asset URLs used in
browser tests and demo recordings. Assets are intercepted and served from
a local cache to eliminate network variability.
This module provides CDN asset URLs used in browser tests and demo recordings.
Assets are intercepted and served from a local cache to eliminate network
variability.
Note: The canonical list of CDN assets for production is in base.html
(with data-vendor attributes). This module includes those plus dynamically
loaded assets (like Monaco editor modules loaded by app.js).
The canonical list of CDN assets is in vendor-assets.json. This module loads
that file and provides the CDN_ASSETS dict for test caching.
"""
from __future__ import annotations
import json
import subprocess
from typing import TYPE_CHECKING
from pathlib import Path
def _load_cdn_assets() -> dict[str, tuple[str, str]]:
"""Load CDN assets from vendor-assets.json.
Returns:
Dict mapping URL to (filename, content_type) tuple.
"""
json_path = Path(__file__).parent / "vendor-assets.json"
with json_path.open() as f:
config = json.load(f)
return {asset["url"]: (asset["filename"], asset["content_type"]) for asset in config["assets"]}
if TYPE_CHECKING:
from pathlib import Path
# CDN assets to cache locally for tests/demos
# Format: URL -> (local_filename, content_type)
#
# If tests fail with "Uncached CDN request", add the URL here.
CDN_ASSETS: dict[str, tuple[str, str]] = {
# From base.html (data-vendor attributes)
"https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"),
"https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"),
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": (
"tailwind.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"),
"https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"),
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": (
"xterm.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": (
"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",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": (
"monaco-editor-main.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": (
"monaco-editor-main.css",
"text/css",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": (
"monaco-workerMain.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": (
"monaco-yaml.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": (
"monaco-codicon.ttf",
"font/ttf",
),
}
# If tests fail with "Uncached CDN request", add the URL to vendor-assets.json.
CDN_ASSETS: dict[str, tuple[str, str]] = _load_cdn_assets()
def download_url(url: str) -> bytes | None:
@@ -107,6 +68,7 @@ def ensure_vendor_cache(cache_dir: Path) -> Path:
filepath = cache_dir / filename
if filepath.exists():
continue
filepath.parent.mkdir(parents=True, exist_ok=True)
content = download_url(url)
if not content:
msg = f"Failed to download {url} - check network/curl"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import os
import uuid
from typing import TYPE_CHECKING, Any
@@ -14,6 +15,9 @@ if TYPE_CHECKING:
from compose_farm.web.deps import get_config
from compose_farm.web.streaming import run_cli_streaming, run_compose_streaming, tasks
# Environment variable to identify the web stack (for exclusion from bulk updates)
CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "")
router = APIRouter(tags=["actions"])
# Store task references to prevent garbage collection
@@ -96,7 +100,15 @@ async def pull_all() -> dict[str, Any]:
@router.post("/update-all")
async def update_all() -> dict[str, Any]:
"""Update all stacks (pull + build + down + up)."""
"""Update all stacks, excluding the web stack. Only recreates if images changed.
The web stack is excluded to prevent the UI from shutting down mid-operation.
Use 'cf update <web-stack>' manually to update the web UI.
"""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
return {"task_id": task_id, "command": "update --all"}
# Get all stacks except the web stack to avoid self-shutdown
stacks = [s for s in config.stacks if s != CF_WEB_STACK]
if not stacks:
return {"task_id": "", "command": "update (no stacks)", "skipped": True}
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", *stacks], tid))
return {"task_id": task_id, "command": f"update {' '.join(stacks)}"}

View File

@@ -332,10 +332,14 @@ function loadMonaco(callback) {
monacoLoading = true;
// Load the Monaco loader script
// Use local paths when running from vendored wheel, CDN otherwise
const monacoBase = window.CF_VENDORED
? '/static/vendor/monaco'
: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js';
script.src = monacoBase + '/loader.js';
script.onload = function() {
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' }});
require.config({ paths: { vs: monacoBase }});
require(['vs/editor/editor.main'], function() {
monacoLoaded = true;
monacoLoading = false;
@@ -604,7 +608,7 @@ function playFabIntro() {
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
cmd('action', 'Update All', 'Update all stacks except web', 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),

View File

@@ -97,8 +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://unpkg.com/idiomorph/dist/idiomorph.min.js" data-vendor="idiomorph.js"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js" data-vendor="idiomorph-ext.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

@@ -18,7 +18,7 @@
{{ action_btn("Apply", "/api/apply", "primary", "Make reality match config", check()) }}
{{ action_btn("Refresh", "/api/refresh", "outline", "Update state from reality", refresh_cw()) }}
{{ action_btn("Pull All", "/api/pull-all", "outline", "Pull latest images for all stacks", cloud_download()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks (pull + build + down + up)", rotate_cw()) }}
{{ action_btn("Update All", "/api/update-all", "outline", "Update all stacks except web (only restarts if changed)", rotate_cw()) }}
<div class="tooltip" data-tip="Save compose-farm.yaml config file"><button id="save-config-btn" class="btn btn-outline">{{ save() }} Save Config</button></div>
</div>

View File

@@ -23,7 +23,7 @@
{{ action_btn("Up", "/api/stack/" ~ name ~ "/up", "primary", "Start stack (docker compose up -d)", play()) }}
{{ action_btn("Down", "/api/stack/" ~ name ~ "/down", "outline", "Stop stack (docker compose down)", square()) }}
{{ action_btn("Restart", "/api/stack/" ~ name ~ "/restart", "secondary", "Restart stack (down + up)", rotate_cw()) }}
{{ action_btn("Update", "/api/stack/" ~ name ~ "/update", "accent", "Update to latest (pull + build + down + up)", download()) }}
{{ action_btn("Update", "/api/stack/" ~ name ~ "/update", "accent", "Update to latest (only restarts if changed)", download()) }}
<div class="divider divider-horizontal mx-0"></div>

View File

@@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "CDN assets vendored into production builds and cached for tests",
"assets": [
{
"url": "https://cdn.jsdelivr.net/npm/daisyui@5",
"filename": "daisyui.css",
"content_type": "text/css",
"package": "daisyui"
},
{
"url": "https://cdn.jsdelivr.net/npm/daisyui@5/themes.css",
"filename": "daisyui-themes.css",
"content_type": "text/css",
"package": "daisyui"
},
{
"url": "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
"filename": "tailwind.js",
"content_type": "application/javascript",
"package": "tailwindcss"
},
{
"url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css",
"filename": "xterm.css",
"content_type": "text/css",
"package": "xterm"
},
{
"url": "https://unpkg.com/htmx.org@2.0.4",
"filename": "htmx.js",
"content_type": "application/javascript",
"package": "htmx"
},
{
"url": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js",
"filename": "xterm.js",
"content_type": "application/javascript",
"package": "xterm"
},
{
"url": "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
"filename": "xterm-fit.js",
"content_type": "application/javascript",
"package": "xterm"
},
{
"url": "https://unpkg.com/idiomorph/dist/idiomorph.min.js",
"filename": "idiomorph.js",
"content_type": "application/javascript",
"package": "idiomorph"
},
{
"url": "https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js",
"filename": "idiomorph-ext.js",
"content_type": "application/javascript",
"package": "idiomorph"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js",
"filename": "monaco/loader.js",
"content_type": "application/javascript",
"package": "monaco-editor"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js",
"filename": "monaco/editor/editor.main.js",
"content_type": "application/javascript",
"package": "monaco-editor"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css",
"filename": "monaco/editor/editor.main.css",
"content_type": "text/css",
"package": "monaco-editor"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js",
"filename": "monaco/base/worker/workerMain.js",
"content_type": "application/javascript",
"package": "monaco-editor"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js",
"filename": "monaco/basic-languages/yaml/yaml.js",
"content_type": "application/javascript",
"package": "monaco-editor"
},
{
"url": "https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf",
"filename": "monaco/base/browser/ui/codicons/codicon/codicon.ttf",
"content_type": "font/ttf",
"package": "monaco-editor"
}
],
"licenses": {
"htmx": {
"type": "MIT",
"url": "https://raw.githubusercontent.com/bigskysoftware/htmx/master/LICENSE"
},
"idiomorph": {
"type": "BSD-2-Clause",
"url": "https://raw.githubusercontent.com/bigskysoftware/idiomorph/main/LICENSE"
},
"xterm": {
"type": "MIT",
"url": "https://raw.githubusercontent.com/xtermjs/xterm.js/master/LICENSE"
},
"daisyui": {
"type": "MIT",
"url": "https://raw.githubusercontent.com/saadeghi/daisyui/master/LICENSE"
},
"tailwindcss": {
"type": "MIT",
"url": "https://raw.githubusercontent.com/tailwindlabs/tailwindcss/master/LICENSE"
},
"monaco-editor": {
"type": "MIT",
"url": "https://raw.githubusercontent.com/microsoft/monaco-editor/main/LICENSE.txt"
}
}
}

View File

@@ -98,19 +98,18 @@ class TestMigrationCommands:
class TestUpdateCommandSequence:
"""Tests for update command sequence."""
def test_update_command_sequence_includes_build(self) -> None:
"""Update command should use pull --ignore-buildable and build."""
def test_update_command_uses_pull_always_and_build(self) -> None:
"""Update command should use --pull always --build flags."""
# This is a static check of the command sequence in lifecycle.py
# The actual command sequence is defined in the update function
source = inspect.getsource(lifecycle.update)
# Verify the command sequence includes pull --ignore-buildable
assert "pull --ignore-buildable" in source
# Verify build is included
assert '"build"' in source or "'build'" in source
# Verify the sequence is pull, build, down, up
assert "down" in source
# Verify the command uses --pull always (only recreates if image changed)
assert "--pull always" in source
# Verify --build is included for buildable services
assert "--build" in source
# Verify up -d is used
assert "up -d" in source

View File

@@ -4,7 +4,7 @@ Run with: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
CDN assets are cached locally (in .pytest_cache/vendor/) to eliminate network
variability. If a test fails with "Uncached CDN request", add the URL to
compose_farm.web.cdn.CDN_ASSETS.
src/compose_farm/web/vendor-assets.json.
"""
from __future__ import annotations
@@ -90,7 +90,7 @@ def page(page: Page, vendor_cache: Path) -> Page:
return
# Uncached CDN request - abort with helpful error
route.abort("failed")
msg = f"Uncached CDN request: {url}\n\nAdd this URL to CDN_ASSETS in tests/web/test_htmx_browser.py"
msg = f"Uncached CDN request: {url}\n\nAdd this URL to src/compose_farm/web/vendor-assets.json"
raise RuntimeError(msg)
page.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)