mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-07 16:02:10 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009f3b1403 | ||
|
|
51f74eab42 | ||
|
|
4acf797128 | ||
|
|
d167da9d63 | ||
|
|
a5eac339db | ||
|
|
9f3813eb72 | ||
|
|
b9ae0ad4d5 |
@@ -59,18 +59,20 @@ Check:
|
||||
- Config file search order is accurate
|
||||
- Example YAML would actually work
|
||||
|
||||
### 4. Verify docs/architecture.md
|
||||
### 4. Verify docs/architecture.md and CLAUDE.md
|
||||
|
||||
```bash
|
||||
# What source files actually exist?
|
||||
git ls-files "src/**/*.py"
|
||||
```
|
||||
|
||||
Check:
|
||||
Check **both** `docs/architecture.md` and `CLAUDE.md` (Architecture section):
|
||||
- Listed files exist
|
||||
- No files are missing from the list
|
||||
- Descriptions match what the code does
|
||||
|
||||
Both files have architecture listings that can drift independently.
|
||||
|
||||
### 5. Check Examples
|
||||
|
||||
For examples in any doc:
|
||||
|
||||
@@ -20,15 +20,17 @@ src/compose_farm/
|
||||
│ ├── monitoring.py # logs, ps, stats commands
|
||||
│ ├── ssh.py # SSH key management (setup, status, keygen)
|
||||
│ └── web.py # Web UI server command
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── compose.py # Compose file parsing (.env, ports, volumes, networks)
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── console.py # Shared Rich console instances
|
||||
├── executor.py # SSH/local command execution, streaming output
|
||||
├── operations.py # Business logic (up, migrate, discover, preflight checks)
|
||||
├── state.py # Deployment state tracking (which stack on which host)
|
||||
├── glances.py # Glances API integration for host resource stats
|
||||
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
|
||||
├── operations.py # Business logic (up, migrate, discover, preflight checks)
|
||||
├── paths.py # Path utilities, config file discovery
|
||||
├── registry.py # Container registry client for update checking
|
||||
├── ssh_keys.py # SSH key path constants and utilities
|
||||
├── state.py # Deployment state tracking (which stack on which host)
|
||||
├── traefik.py # Traefik file-provider config generation from labels
|
||||
└── web/ # Web UI (FastAPI + HTMX)
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ Typer-based CLI with subcommand modules:
|
||||
cli/
|
||||
├── app.py # Shared Typer app, version callback
|
||||
├── common.py # Shared helpers, options, progress utilities
|
||||
├── config.py # config subcommand (init, show, path, validate, edit, symlink)
|
||||
├── config.py # config subcommand (init, init-env, show, path, validate, edit, symlink)
|
||||
├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose
|
||||
├── management.py # refresh, check, init-network, traefik-file
|
||||
├── monitoring.py # logs, ps, stats
|
||||
@@ -343,3 +343,19 @@ For repeated connections to the same host, SSH reuses connections.
|
||||
```
|
||||
|
||||
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html`.
|
||||
|
||||
### Host Resource Monitoring (`src/compose_farm/glances.py`)
|
||||
|
||||
Integration with [Glances](https://nicolargo.github.io/glances/) for real-time host stats:
|
||||
|
||||
- Fetches CPU, memory, and load from Glances REST API on each host
|
||||
- Used by web UI dashboard to display host resource usage
|
||||
- Requires `glances_stack` config option pointing to a Glances stack running on all hosts
|
||||
|
||||
### Container Registry Client (`src/compose_farm/registry.py`)
|
||||
|
||||
OCI Distribution API client for checking image updates:
|
||||
|
||||
- Parses image references (registry, namespace, name, tag, digest)
|
||||
- Fetches available tags from Docker Hub, GHCR, and other registries
|
||||
- Compares semantic versions to find newer releases
|
||||
|
||||
@@ -38,6 +38,18 @@ cf --version, -v # Show version
|
||||
cf --help, -h # Show help
|
||||
```
|
||||
|
||||
## Command Aliases
|
||||
|
||||
Short aliases for frequently used commands:
|
||||
|
||||
| Alias | Command | Alias | Command |
|
||||
|-------|---------|-------|---------|
|
||||
| `cf a` | `apply` | `cf s` | `stats` |
|
||||
| `cf l` | `logs` | `cf c` | `compose` |
|
||||
| `cf r` | `restart` | `cf rf` | `refresh` |
|
||||
| `cf u` | `update` | `cf ck` | `check` |
|
||||
| `cf p` | `pull` | `cf tf` | `traefik-file` |
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Commands
|
||||
@@ -60,14 +72,16 @@ cf apply [OPTIONS]
|
||||
|--------|-------------|
|
||||
| `--dry-run, -n` | Preview changes without executing |
|
||||
| `--no-orphans` | Skip stopping orphaned stacks |
|
||||
| `--full, -f` | Also refresh running stacks |
|
||||
| `--no-strays` | Skip stopping stray stacks (running on wrong host) |
|
||||
| `--full, -f` | Also run up on all stacks (applies compose/env changes, triggers migrations) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**What it does:**
|
||||
|
||||
1. Stops orphaned stacks (in state but removed from config)
|
||||
2. Migrates stacks on wrong host
|
||||
3. Starts missing stacks (in config but not running)
|
||||
2. Stops stray stacks (running on unauthorized hosts)
|
||||
3. Migrates stacks on wrong host
|
||||
4. Starts missing stacks (in config but not running)
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -81,7 +95,10 @@ cf apply
|
||||
# Only start/migrate, don't stop orphans
|
||||
cf apply --no-orphans
|
||||
|
||||
# Also refresh all running stacks
|
||||
# Don't stop stray stacks
|
||||
cf apply --no-strays
|
||||
|
||||
# Also run up on all stacks (applies compose/env changes, triggers migrations)
|
||||
cf apply --full
|
||||
```
|
||||
|
||||
@@ -102,6 +119,8 @@ cf up [OPTIONS] [STACKS]...
|
||||
| `--all, -a` | Start all stacks |
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--pull` | Pull images before starting (`--pull always`) |
|
||||
| `--build` | Build images before starting |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
@@ -589,6 +608,7 @@ cf config COMMAND
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `init` | Create new config with examples |
|
||||
| `init-env` | Generate .env file for Docker deployment |
|
||||
| `show` | Display config with highlighting |
|
||||
| `path` | Print config file path |
|
||||
| `validate` | Validate syntax and schema |
|
||||
@@ -600,6 +620,7 @@ cf config COMMAND
|
||||
| Subcommand | Options |
|
||||
|------------|---------|
|
||||
| `init` | `--path/-p PATH`, `--force/-f` |
|
||||
| `init-env` | `--path/-p PATH`, `--output/-o PATH`, `--force/-f` |
|
||||
| `show` | `--path/-p PATH`, `--raw/-r` |
|
||||
| `edit` | `--path/-p PATH` |
|
||||
| `path` | `--path/-p PATH` |
|
||||
@@ -635,6 +656,12 @@ cf config symlink
|
||||
|
||||
# Create symlink to specific file
|
||||
cf config symlink /opt/compose-farm/config.yaml
|
||||
|
||||
# Generate .env file for Docker deployment
|
||||
cf config init-env
|
||||
|
||||
# Generate .env in current directory
|
||||
cf config init-env -o .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -121,6 +121,16 @@ Stack name running Traefik. Stacks on the same host are skipped in file-provider
|
||||
traefik_stack: traefik
|
||||
```
|
||||
|
||||
### glances_stack
|
||||
|
||||
Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the web UI displays CPU, memory, and load stats for all hosts.
|
||||
|
||||
```yaml
|
||||
glances_stack: glances
|
||||
```
|
||||
|
||||
The Glances stack should run on all hosts and expose port 61208. See the README for full setup instructions.
|
||||
|
||||
## Hosts Configuration
|
||||
|
||||
### Basic Host
|
||||
|
||||
@@ -7,9 +7,10 @@ Real-world examples demonstrating compose-farm patterns for multi-host Docker de
|
||||
| Stack | Type | Demonstrates |
|
||||
|---------|------|--------------|
|
||||
| [traefik](traefik/) | Infrastructure | Reverse proxy, Let's Encrypt, file-provider |
|
||||
| [coredns](coredns/) | Infrastructure | Wildcard DNS for `*.local` domains |
|
||||
| [mealie](mealie/) | Single container | Traefik labels, resource limits, environment vars |
|
||||
| [uptime-kuma](uptime-kuma/) | Single container | Docker socket, user mapping, custom DNS |
|
||||
| [paperless-ngx](paperless-ngx/) | Multi-container | Redis + App stack (SQLite) |
|
||||
| [paperless-ngx](paperless-ngx/) | Multi-container | Redis + PostgreSQL + App stack |
|
||||
| [autokuma](autokuma/) | Multi-host | Demonstrates `all` keyword (runs on every host) |
|
||||
|
||||
## Key Patterns
|
||||
@@ -53,7 +54,8 @@ labels:
|
||||
- traefik.http.routers.myapp-local.entrypoints=web
|
||||
```
|
||||
|
||||
> **Note:** `.local` domains require local DNS (e.g., Pi-hole, Technitium) to resolve to your Traefik host.
|
||||
> **Note:** `.local` domains require local DNS to resolve to your Traefik host.
|
||||
> The [coredns](coredns/) example provides this - edit `Corefile` to set your Traefik IP.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
@@ -88,23 +90,6 @@ stacks:
|
||||
autokuma: all # Runs on every configured host
|
||||
```
|
||||
|
||||
### Multi-Container Stacks
|
||||
|
||||
Database-backed apps with multiple services:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
app:
|
||||
depends_on:
|
||||
- redis
|
||||
```
|
||||
|
||||
> **NFS + PostgreSQL Warning:** PostgreSQL should NOT run on NFS storage due to
|
||||
> fsync and file locking issues. Use SQLite (safe for single-writer on NFS) or
|
||||
> keep PostgreSQL data on local volumes (non-migratable).
|
||||
|
||||
### AutoKuma Labels (Optional)
|
||||
|
||||
The autokuma example demonstrates compose-farm's **multi-host feature** - running the same stack on all hosts using the `all` keyword. AutoKuma itself is not part of compose-farm; it's just a good example because it needs to run on every host to monitor local Docker containers.
|
||||
@@ -125,8 +110,8 @@ cd examples
|
||||
# 1. Create the shared network on all hosts
|
||||
compose-farm init-network
|
||||
|
||||
# 2. Start Traefik first (the reverse proxy)
|
||||
compose-farm up traefik
|
||||
# 2. Start infrastructure (reverse proxy + DNS)
|
||||
compose-farm up traefik coredns
|
||||
|
||||
# 3. Start other stacks
|
||||
compose-farm up mealie uptime-kuma
|
||||
|
||||
@@ -3,6 +3,7 @@ deployed:
|
||||
- primary
|
||||
- secondary
|
||||
- local
|
||||
coredns: primary
|
||||
mealie: secondary
|
||||
paperless-ngx: primary
|
||||
traefik: primary
|
||||
|
||||
@@ -27,6 +27,7 @@ hosts:
|
||||
stacks:
|
||||
# Infrastructure (runs on primary where Traefik is)
|
||||
traefik: primary
|
||||
coredns: primary # DNS for *.local resolution
|
||||
|
||||
# Multi-host stacks (runs on ALL hosts)
|
||||
# AutoKuma monitors Docker containers on each host
|
||||
|
||||
2
examples/coredns/.env
Normal file
2
examples/coredns/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# CoreDNS doesn't need environment variables
|
||||
# The Traefik IP is configured in the Corefile
|
||||
22
examples/coredns/Corefile
Normal file
22
examples/coredns/Corefile
Normal file
@@ -0,0 +1,22 @@
|
||||
# CoreDNS configuration for .local domain resolution
|
||||
#
|
||||
# Resolves *.local to the Traefik host IP (where your reverse proxy runs).
|
||||
# All other queries are forwarded to upstream DNS.
|
||||
|
||||
# Handle .local domains - resolve everything to Traefik's host
|
||||
local {
|
||||
template IN A {
|
||||
answer "{{ .Name }} 60 IN A 192.168.1.10"
|
||||
}
|
||||
template IN AAAA {
|
||||
# Return empty for AAAA to avoid delays on IPv4-only networks
|
||||
rcode NOERROR
|
||||
}
|
||||
}
|
||||
|
||||
# Forward everything else to upstream DNS
|
||||
. {
|
||||
forward . 1.1.1.1 8.8.8.8
|
||||
cache 300
|
||||
errors
|
||||
}
|
||||
27
examples/coredns/compose.yaml
Normal file
27
examples/coredns/compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
# CoreDNS - DNS server for .local domain resolution
|
||||
#
|
||||
# Demonstrates:
|
||||
# - Wildcard DNS for *.local domains
|
||||
# - Config file mounting from stack directory
|
||||
# - UDP/TCP port exposure
|
||||
#
|
||||
# This enables all the .local routes in the examples to work.
|
||||
# Point your devices/router DNS to this server's IP.
|
||||
name: coredns
|
||||
services:
|
||||
coredns:
|
||||
image: coredns/coredns:latest
|
||||
container_name: coredns
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "53:53/udp"
|
||||
- "53:53/tcp"
|
||||
volumes:
|
||||
- ./Corefile:/root/Corefile:ro
|
||||
command: -conf /root/Corefile
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
@@ -1,3 +1,4 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
PAPERLESS_SECRET_KEY=change-me-to-a-random-string
|
||||
POSTGRES_PASSWORD=change-me-to-a-secure-password
|
||||
PAPERLESS_SECRET_KEY=change-me-to-a-long-random-string
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
# Paperless-ngx - Document management system
|
||||
#
|
||||
# Demonstrates:
|
||||
# - HTTPS route: paperless.${DOMAIN} (e.g., paperless.example.com) with Let's Encrypt
|
||||
# - HTTP route: paperless.local for LAN access without TLS
|
||||
# - Multi-container stack (Redis + App with SQLite)
|
||||
#
|
||||
# NOTE: This example uses SQLite (the default) instead of PostgreSQL.
|
||||
# PostgreSQL should NOT be used with NFS storage due to fsync/locking issues.
|
||||
# If you need PostgreSQL, use local volumes for the database.
|
||||
# - HTTPS route: paperless.${DOMAIN} with Let's Encrypt
|
||||
# - HTTP route: paperless.local for LAN access
|
||||
# - Multi-container stack (Redis + PostgreSQL + App)
|
||||
# - Separate env_file for app-specific settings
|
||||
name: paperless-ngx
|
||||
services:
|
||||
redis:
|
||||
image: redis:8
|
||||
broker:
|
||||
image: redis:7
|
||||
container_name: paperless-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mynetwork
|
||||
volumes:
|
||||
- /mnt/data/paperless/redis:/data
|
||||
- /mnt/data/paperless/redisdata:/data
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: paperless-db
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mynetwork
|
||||
volumes:
|
||||
- /mnt/data/paperless/pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: paperless
|
||||
POSTGRES_USER: paperless
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
|
||||
paperless:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||
container_name: paperless
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- broker
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# SQLite database stored here (safe on NFS for single-writer)
|
||||
- /mnt/data/paperless/data:/usr/src/paperless/data
|
||||
- /mnt/data/paperless/media:/usr/src/paperless/media
|
||||
- /mnt/data/paperless/export:/usr/src/paperless/export
|
||||
- /mnt/data/paperless/consume:/usr/src/paperless/consume
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://redis:6379
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_DBHOST: db
|
||||
PAPERLESS_URL: https://paperless.${DOMAIN}
|
||||
PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY}
|
||||
PAPERLESS_TIME_ZONE: America/Los_Angeles
|
||||
PAPERLESS_OCR_LANGUAGE: eng
|
||||
USERMAP_UID: 1000
|
||||
USERMAP_GID: 1000
|
||||
labels:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
@@ -393,10 +394,10 @@ def compose(
|
||||
else:
|
||||
target_host = hosts[0]
|
||||
|
||||
# Build the full compose command
|
||||
# Build the full compose command (quote args to preserve spaces)
|
||||
full_cmd = command
|
||||
if args:
|
||||
full_cmd += " " + " ".join(args)
|
||||
full_cmd += " " + " ".join(shlex.quote(arg) for arg in args)
|
||||
|
||||
# Run with raw=True for proper TTY handling (progress bars, interactive)
|
||||
result = run_async(run_compose_on_host(cfg, resolved_stack, target_host, full_cmd, raw=True))
|
||||
|
||||
@@ -280,8 +280,11 @@ def parse_external_networks(config: Config, stack: str) -> list[str]:
|
||||
return []
|
||||
|
||||
external_networks: list[str] = []
|
||||
for name, definition in networks.items():
|
||||
for key, definition in networks.items():
|
||||
if isinstance(definition, dict) and definition.get("external") is True:
|
||||
# Networks may have a "name" field, which may differ from the key.
|
||||
# Use it if present, else fall back to the key.
|
||||
name = str(definition.get("name", key))
|
||||
external_networks.append(name)
|
||||
|
||||
return external_networks
|
||||
|
||||
@@ -551,7 +551,6 @@ function playFabIntro() {
|
||||
let commands = [];
|
||||
let filtered = [];
|
||||
let selected = 0;
|
||||
let originalTheme = null; // Store theme when palette opens for preview/restore
|
||||
|
||||
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
|
||||
const nav = (url, afterNav) => () => {
|
||||
@@ -575,20 +574,21 @@ function playFabIntro() {
|
||||
}
|
||||
htmx.ajax('POST', `/api/${endpoint}`, {swap: 'none'});
|
||||
};
|
||||
// Get saved theme from localStorage (source of truth)
|
||||
const getSavedTheme = () => localStorage.getItem(THEME_KEY) || 'dark';
|
||||
|
||||
// Apply theme and save to localStorage
|
||||
const setTheme = (theme) => () => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
};
|
||||
// Preview theme without saving (for hover)
|
||||
// Preview theme without saving (for hover). Guards against undefined/invalid themes.
|
||||
const previewTheme = (theme) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
if (theme) document.documentElement.setAttribute('data-theme', theme);
|
||||
};
|
||||
// Restore original theme (when closing without selection)
|
||||
// Restore theme from localStorage (source of truth)
|
||||
const restoreTheme = () => {
|
||||
if (originalTheme) {
|
||||
document.documentElement.setAttribute('data-theme', originalTheme);
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', getSavedTheme());
|
||||
};
|
||||
// Generate color swatch HTML for a theme
|
||||
const themeSwatch = (theme) => `<span class="flex gap-0.5" data-theme="${theme}"><span class="w-2 h-4 rounded-l bg-primary"></span><span class="w-2 h-4 bg-secondary"></span><span class="w-2 h-4 bg-accent"></span><span class="w-2 h-4 rounded-r bg-neutral"></span></span>`;
|
||||
@@ -721,26 +721,24 @@ function playFabIntro() {
|
||||
// Scroll selected item into view
|
||||
const sel = list.querySelector(`[data-idx="${selected}"]`);
|
||||
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||
// Preview theme if selected item is a theme command
|
||||
// Preview theme if selected item is a theme command, otherwise restore saved
|
||||
const selectedCmd = filtered[selected];
|
||||
if (selectedCmd?.themeId) {
|
||||
previewTheme(selectedCmd.themeId);
|
||||
} else if (originalTheme) {
|
||||
// Restore original when navigating away from theme commands
|
||||
previewTheme(originalTheme);
|
||||
} else {
|
||||
restoreTheme();
|
||||
}
|
||||
}
|
||||
|
||||
function open(initialFilter = '') {
|
||||
// Store original theme for preview/restore
|
||||
originalTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
buildCommands();
|
||||
selected = 0;
|
||||
input.value = initialFilter;
|
||||
filter();
|
||||
// If opening theme picker, select current theme
|
||||
if (initialFilter.startsWith('theme:')) {
|
||||
const currentIdx = filtered.findIndex(c => c.themeId === originalTheme);
|
||||
const savedTheme = getSavedTheme();
|
||||
const currentIdx = filtered.findIndex(c => c.themeId === savedTheme);
|
||||
if (currentIdx >= 0) selected = currentIdx;
|
||||
}
|
||||
render();
|
||||
@@ -751,10 +749,6 @@ function playFabIntro() {
|
||||
function exec() {
|
||||
const cmd = filtered[selected];
|
||||
if (cmd) {
|
||||
if (cmd.themeId) {
|
||||
// Theme command commits the previewed choice.
|
||||
originalTheme = null;
|
||||
}
|
||||
dialog.close();
|
||||
cmd.action();
|
||||
}
|
||||
@@ -794,19 +788,14 @@ function playFabIntro() {
|
||||
if (a) previewTheme(a.dataset.themeId);
|
||||
});
|
||||
|
||||
// Mouse leaving list restores to selected item's theme (or original)
|
||||
// Mouse leaving list restores to selected item's theme (or saved)
|
||||
list.addEventListener('mouseleave', () => {
|
||||
const cmd = filtered[selected];
|
||||
previewTheme(cmd?.themeId || originalTheme);
|
||||
previewTheme(cmd?.themeId || getSavedTheme());
|
||||
});
|
||||
|
||||
// Restore theme when dialog closes without selection (Escape, backdrop click)
|
||||
dialog.addEventListener('close', () => {
|
||||
if (originalTheme) {
|
||||
restoreTheme();
|
||||
originalTheme = null;
|
||||
}
|
||||
});
|
||||
// Restore theme from localStorage when dialog closes
|
||||
dialog.addEventListener('close', restoreTheme);
|
||||
|
||||
// FAB click to open
|
||||
if (fab) fab.addEventListener('click', () => open());
|
||||
|
||||
@@ -338,6 +338,26 @@ def test_parse_external_networks_missing_compose(tmp_path: Path) -> None:
|
||||
assert networks == []
|
||||
|
||||
|
||||
def test_parse_external_networks_with_name_field(tmp_path: Path) -> None:
|
||||
"""Network with 'name' field uses actual name, not key."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
stacks={"app": "host1"},
|
||||
)
|
||||
compose_path = tmp_path / "app" / "compose.yaml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{
|
||||
"services": {"app": {"image": "nginx"}},
|
||||
"networks": {"default": {"name": "compose-net", "external": True}},
|
||||
},
|
||||
)
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == ["compose-net"]
|
||||
|
||||
|
||||
class TestExtractWebsiteUrls:
|
||||
"""Test extract_website_urls function."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user