├── VERSION ├── desk.jpeg ├── mobile.png ├── desk_edit.jpeg ├── static ├── logo.png ├── composr.png ├── logo.svg ├── logo-musical.svg ├── css │ ├── caddy.css │ ├── editor.css │ └── updates.css └── js │ ├── backup.js │ ├── table-view.js │ └── remote.js ├── requirements.txt ├── Screenshot_20250425-142844.jpg ├── Screenshot 2025-05-09 at 13-26-55 Composr.png ├── .gitignore ├── docker-compose.yml ├── docker-composeexample.yaml ├── LICENSE ├── .dockerignore ├── Dockerfile ├── functions.py ├── remote_hosts.py ├── CHANGELOG.md ├── release.sh ├── README.md └── templates └── index.html /VERSION: -------------------------------------------------------------------------------- 1 | 1.7.7 2 | -------------------------------------------------------------------------------- /desk.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/desk.jpeg -------------------------------------------------------------------------------- /mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/mobile.png -------------------------------------------------------------------------------- /desk_edit.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/desk_edit.jpeg -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/static/logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | docker 3 | PyYAML 4 | pytz 5 | gunicorn 6 | requests>=2.25.0 -------------------------------------------------------------------------------- /static/composr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/static/composr.png -------------------------------------------------------------------------------- /Screenshot_20250425-142844.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/Screenshot_20250425-142844.jpg -------------------------------------------------------------------------------- /Screenshot 2025-05-09 at 13-26-55 Composr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vansmak/composr/HEAD/Screenshot 2025-05-09 at 13-26-55 Composr.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | venv/ 9 | ENV/ 10 | 11 | # IDE 12 | .vscode/ 13 | .idea/ 14 | *.sublime-* 15 | 16 | # Logs and data 17 | *.log 18 | container_metadata.json 19 | metadata/ 20 | 21 | # Environment 22 | .env 23 | .env.local 24 | 25 | # OS 26 | .DS_Store 27 | Thumbs.db 28 | 29 | # Testing 30 | .pytest_cache/ 31 | .coverage 32 | htmlcov/ 33 | 34 | # Build 35 | build/ 36 | dist/ 37 | *.egg-info/ 38 | 39 | # Temporary files 40 | *.tmp 41 | *.bak 42 | *.swp 43 | *~ -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | ## use this build and run locally 2 | version: '3' 3 | services: 4 | composr: 5 | build: . 6 | user: "1000:1000" 7 | 8 | ports: 9 | - "5003:5003" 10 | volumes: 11 | - /var/run/docker.sock:/var/run/docker.sock:rw 12 | - ./:/app # Mount code for development 13 | - /home/joe/docker:/home/joe/docker # Your home directory with all compose files 14 | - /home/joe/projects:/home/joe/projects 15 | - /home/joe/config/caddy:/home/joe/config/caddy # Caddy config directory 16 | - ./metadata:/app/metadata # Store metadata persistently 17 | environment: 18 | - COMPOSE_DIR=/home/joe/docker # Look for compose files here 19 | - EXTRA_COMPOSE_DIRS=/home/joe/projects # Extra directories to scan, my example 20 | - METADATA_DIR=/app/metadata # Store settings here 21 | - CADDY_CONFIG_DIR=/home/joe/config/caddy 22 | restart: unless-stopped -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | composr 17 | -------------------------------------------------------------------------------- /docker-composeexample.yaml: -------------------------------------------------------------------------------- 1 | ##use with docker hub image 2 | services: 3 | composr: 4 | image: vansmak/composr:latest 5 | container_name: composr 6 | ports: 7 | - "5003:5003" 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | - /home/joe/docker:/app/projects # Mounting your docker folder as /app/projects 11 | - /home/joe/config/composr:/app/data 12 | ##optionals 13 | - /home/joe/config/caddy:/caddy_config 14 | - /path1:/path1 # EXTRA_COMPOSE_DIRS path1 15 | - /path2:/path2 # EXTRA_COMPOSE_DIRS path2 16 | - /path3:/path3etc # EXTRA_COMPOSE_DIRS path3 17 | environment: 18 | - DEBUG=false 19 | - COMPOSE_DIR=/app/projects 20 | - METADATA_DIR=/app/data 21 | - CONTAINER_METADATA_FILE=/app/data/metadata.json 22 | ###optionals 23 | - EXTRA_COMPOSE_DIRS: /path1:/path2:/path3 # Optional 24 | - CADDY_CONFIG_DIR=/caddy_config 25 | restart: unless-stopped 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Vansmak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Development/test files 2 | htmlcov/ 3 | *~ 4 | 5 | # Personal configuration files that shouldn't be in the image 6 | container_metadata.json 7 | container_updates_cache.json 8 | docker_hosts.json 9 | composr_hosts.json 10 | composr.log 11 | metadata/ 12 | data/ 13 | logs/ 14 | *.log 15 | 16 | # Runtime files 17 | app.log 18 | debug.log 19 | 20 | # Version control 21 | .git 22 | .gitignore 23 | 24 | # Python 25 | __pycache__ 26 | *.pyc 27 | *.pyo 28 | *.pyd 29 | .Python 30 | env 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | .tox 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | *.log 41 | .mypy_cache 42 | .pytest_cache 43 | .hypothesis 44 | 45 | # OS 46 | .DS_Store 47 | .DS_Store? 48 | ._* 49 | .Spotlight-V100 50 | .Trashes 51 | ehthumbs.db 52 | Thumbs.db 53 | 54 | # IDEs 55 | .vscode 56 | .idea 57 | *.swp 58 | *.swo 59 | 60 | # Node (if you have any frontend build tools) 61 | node_modules 62 | npm-debug.log* 63 | yarn-debug.log* 64 | yarn-error.log* 65 | 66 | # Build artifacts 67 | build/ 68 | dist/ 69 | *.egg-info/ 70 | 71 | # Docker 72 | Dockerfile* 73 | docker-compose*.yml 74 | .dockerignore 75 | 76 | # Development files 77 | tests/ 78 | test_* 79 | *_test.py 80 | .pytest_cache/ 81 | .env.local 82 | .env.development 83 | 84 | # Build/release scripts (not needed in container) 85 | release.sh 86 | test-release.sh 87 | build.sh -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage build for size optimization 2 | FROM python:3.9-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache \ 6 | gcc \ 7 | musl-dev \ 8 | linux-headers \ 9 | curl 10 | 11 | # Set working directory 12 | WORKDIR /app 13 | 14 | # Copy and install Python dependencies GLOBALLY (not --user) 15 | COPY requirements.txt . 16 | RUN pip install --no-cache-dir -r requirements.txt 17 | 18 | # Production stage 19 | FROM python:3.9-alpine 20 | 21 | # Install runtime dependencies only 22 | RUN apk add --no-cache \ 23 | curl \ 24 | ca-certificates 25 | 26 | # Install Docker CLI - multi-architecture aware 27 | RUN DOCKER_VERSION=24.0.7 \ 28 | && TARGETARCH=$(uname -m) \ 29 | && case ${TARGETARCH} in \ 30 | x86_64) DOCKER_ARCH="x86_64" ;; \ 31 | aarch64) DOCKER_ARCH="aarch64" ;; \ 32 | armv7l) DOCKER_ARCH="armhf" ;; \ 33 | armv6l) DOCKER_ARCH="armel" ;; \ 34 | *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ 35 | esac \ 36 | && curl -fsSL "https://download.docker.com/linux/static/stable/${DOCKER_ARCH}/docker-${DOCKER_VERSION}.tgz" | tar xz --strip-components=1 -C /usr/local/bin docker/docker \ 37 | && chmod +x /usr/local/bin/docker 38 | 39 | # Install Docker Compose - multi-architecture aware 40 | RUN DOCKER_COMPOSE_VERSION=v2.23.1 \ 41 | && TARGETARCH=$(uname -m) \ 42 | && case ${TARGETARCH} in \ 43 | x86_64) COMPOSE_ARCH="x86_64" ;; \ 44 | aarch64) COMPOSE_ARCH="aarch64" ;; \ 45 | armv7l) COMPOSE_ARCH="armv7" ;; \ 46 | armv6l) COMPOSE_ARCH="armv6" ;; \ 47 | *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ 48 | esac \ 49 | && curl -L "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-${COMPOSE_ARCH}" -o /usr/local/bin/docker-compose \ 50 | && chmod +x /usr/local/bin/docker-compose 51 | 52 | # Copy Python packages from builder stage (now installed globally) 53 | COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages 54 | COPY --from=builder /usr/local/bin /usr/local/bin 55 | 56 | # Set working directory 57 | WORKDIR /app 58 | 59 | # Copy application files 60 | COPY . . 61 | 62 | # Create a non-root user (optional, but good practice) 63 | RUN adduser -D -u 1000 composr 64 | 65 | # Make sure composr user can access the app directory 66 | RUN chown -R composr:composr /app 67 | 68 | # Expose port 69 | EXPOSE 5003 70 | 71 | # Command to run the application (can now run as any user) 72 | CMD ["gunicorn", "--bind", "0.0.0.0:5003", "--workers", "4", "--threads", "2", "--timeout", "120", "app:app"] -------------------------------------------------------------------------------- /static/logo-musical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | COMPOSR 58 | -------------------------------------------------------------------------------- /static/css/caddy.css: -------------------------------------------------------------------------------- 1 | /* Mode toggle */ 2 | .caddy-mode-toggle { 3 | display: flex; 4 | margin-bottom: 15px; 5 | } 6 | 7 | .caddy-mode-btn { 8 | padding: 8px 15px; 9 | background: var(--btn-bg); 10 | border: 1px solid var(--border-color); 11 | border-radius: 4px; 12 | margin-right: 10px; 13 | cursor: pointer; 14 | color: var(--text-color); 15 | } 16 | 17 | .caddy-mode-btn.active { 18 | background: var(--primary-color); 19 | color: white; 20 | border-color: var(--primary-color); 21 | } 22 | 23 | /* Editor modes */ 24 | .caddy-editor-mode { 25 | display: none; 26 | } 27 | 28 | .caddy-editor-mode.active { 29 | display: block; 30 | } 31 | 32 | /* Sites list */ 33 | .site-list-container { 34 | margin-bottom: 20px; 35 | } 36 | 37 | .site-list-header { 38 | display: flex; 39 | justify-content: space-between; 40 | align-items: center; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .sites-list { 45 | border: 1px solid var(--border-color); 46 | border-radius: 4px; 47 | max-height: 300px; 48 | overflow-y: auto; 49 | } 50 | 51 | .site-item { 52 | display: flex; 53 | justify-content: space-between; 54 | align-items: center; 55 | padding: 10px; 56 | border-bottom: 1px solid var(--border-color); 57 | cursor: pointer; 58 | } 59 | 60 | .site-item:last-child { 61 | border-bottom: none; 62 | } 63 | 64 | .site-item.active { 65 | background: rgba(var(--primary-rgb), 0.1); 66 | } 67 | 68 | /* Site editor */ 69 | .site-editor { 70 | border: 1px solid var(--border-color); 71 | border-radius: 4px; 72 | padding: 15px; 73 | background: var(--card-bg); 74 | margin-bottom: 20px; 75 | display: none; 76 | } 77 | 78 | /* Form styling */ 79 | .form-group { 80 | margin-bottom: 15px; 81 | } 82 | 83 | .form-group label { 84 | display: block; 85 | margin-bottom: 5px; 86 | font-weight: 500; 87 | } 88 | 89 | .form-group input, 90 | .form-group select { 91 | width: 100%; 92 | padding: 8px; 93 | border: 1px solid var(--border-color); 94 | border-radius: 4px; 95 | background: var(--input-bg); 96 | color: var(--text-color); 97 | } 98 | 99 | .form-actions { 100 | display: flex; 101 | justify-content: flex-end; 102 | gap: 10px; 103 | } 104 | 105 | /* Text editor */ 106 | #caddy-editor { 107 | width: 100%; 108 | height: 400px; 109 | font-family: monospace; 110 | padding: 10px; 111 | border: 1px solid var(--border-color); 112 | border-radius: 4px; 113 | background: var(--input-bg); 114 | color: var(--text-color); 115 | margin-bottom: 15px; 116 | } 117 | 118 | /* Action buttons */ 119 | .editor-actions { 120 | display: flex; 121 | justify-content: flex-end; 122 | gap: 10px; 123 | margin-top: 15px; 124 | } 125 | /* Container popup */ 126 | .container-popup { 127 | position: fixed; 128 | top: 0; 129 | left: 0; 130 | right: 0; 131 | bottom: 0; 132 | background: rgba(0, 0, 0, 0.5); 133 | display: flex; 134 | align-items: center; 135 | justify-content: center; 136 | z-index: 1000; 137 | } 138 | 139 | .popup-content { 140 | background: var(--card-bg); 141 | border-radius: 4px; 142 | padding: 20px; 143 | width: 80%; 144 | max-width: 500px; 145 | max-height: 80vh; 146 | overflow-y: auto; 147 | } 148 | 149 | .container-list { 150 | margin: 15px 0; 151 | border: 1px solid var(--border-color); 152 | border-radius: 4px; 153 | max-height: 300px; 154 | overflow-y: auto; 155 | } 156 | 157 | .container-item { 158 | display: flex; 159 | justify-content: space-between; 160 | align-items: center; 161 | padding: 10px; 162 | border-bottom: 1px solid var(--border-color); 163 | } 164 | 165 | .container-item:last-child { 166 | border-bottom: none; 167 | } -------------------------------------------------------------------------------- /functions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import datetime 4 | import pytz 5 | import yaml 6 | import docker 7 | from functools import lru_cache 8 | 9 | def initialize_docker_client(logger): 10 | """Initialize Docker client""" 11 | try: 12 | client = docker.from_env() 13 | return client 14 | except Exception as e: 15 | logger.error(f"Failed to initialize Docker client: {e}") 16 | return None 17 | 18 | def load_container_metadata(metadata_file, logger): 19 | """Load container metadata from file""" 20 | try: 21 | if os.path.exists(metadata_file): 22 | with open(metadata_file, 'r') as f: 23 | return json.load(f) 24 | return {} 25 | except Exception as e: 26 | logger.error(f"Failed to load container metadata: {e}") 27 | return {} 28 | 29 | def save_container_metadata(metadata, metadata_file, logger): 30 | """Save container metadata to file""" 31 | try: 32 | with open(metadata_file, 'w') as f: 33 | json.dump(metadata, f) 34 | return True 35 | except Exception as e: 36 | logger.error(f"Failed to save container metadata: {e}") 37 | return False 38 | 39 | def calculate_uptime(started_at, logger): 40 | """Calculate container uptime from start time""" 41 | if not started_at: 42 | return {"display": "N/A", "minutes": 0} 43 | try: 44 | started = datetime.datetime.strptime(started_at[:19], "%Y-%m-%dT%H:%M:%S") 45 | started = started.replace(tzinfo=pytz.UTC) 46 | now = datetime.datetime.now(pytz.UTC) 47 | delta = now - started 48 | days = delta.days 49 | hours = delta.seconds // 3600 50 | minutes = (delta.seconds % 3600) // 60 51 | total_minutes = days * 24 * 60 + hours * 60 + minutes 52 | if days > 0: 53 | display = f"{days}d {hours}h" 54 | elif hours > 0: 55 | display = f"{hours}h {minutes}m" 56 | else: 57 | display = f"{minutes}m" 58 | return {"display": display, "minutes": total_minutes} 59 | except Exception as e: 60 | logger.error(f"Failed to calculate uptime: {e}") 61 | return {"display": "N/A", "minutes": 0} 62 | 63 | @lru_cache(maxsize=32) 64 | def get_compose_files_cached(compose_dir, extra_dirs): 65 | """Cached version of get_compose_files""" 66 | import logging 67 | logger = logging.getLogger(__name__) 68 | compose_files = get_compose_files(compose_dir, extra_dirs, logger) 69 | return compose_files 70 | 71 | def get_compose_files(compose_dir, extra_dirs, logger): 72 | """Get all compose files in the configured directories, returning relative paths""" 73 | try: 74 | compose_files = [] 75 | search_dirs = [compose_dir] + [d for d in extra_dirs if d] 76 | 77 | # Only allow these specific filenames 78 | valid_filenames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'] 79 | 80 | for search_dir in search_dirs: 81 | if logger: 82 | logger.debug(f"Searching directory: {search_dir}") 83 | if not os.path.exists(search_dir): 84 | if logger: 85 | logger.warning(f"Search directory doesn't exist: {search_dir}") 86 | continue 87 | 88 | for root, dirs, files in os.walk(search_dir, topdown=True): 89 | dirs[:] = [d for d in dirs if not d.startswith('.')] 90 | for file in files: 91 | # Only include files that match exactly our valid filenames 92 | if file in valid_filenames: 93 | file_path = os.path.join(root, file) 94 | try: 95 | relative_path = os.path.relpath(file_path, compose_dir) 96 | relative_path = relative_path.replace(os.sep, '/') 97 | compose_files.append(relative_path) 98 | if logger: 99 | logger.debug(f"Found compose file: {relative_path}") 100 | except ValueError as e: 101 | if logger: 102 | logger.warning(f"Failed to compute relative path for {file_path}: {e}") 103 | continue 104 | 105 | if logger: 106 | logger.info(f"Total compose files found: {len(compose_files)}") 107 | return sorted(compose_files) 108 | except Exception as e: 109 | if logger: 110 | logger.error(f"Failed to find compose files: {e}", exc_info=True) 111 | raise 112 | 113 | # Apply same fix to scan_all_compose_files function 114 | def scan_all_compose_files(compose_dir, extra_dirs, logger): 115 | """Scan for all compose files, returning relative paths""" 116 | try: 117 | compose_files = [] 118 | search_dirs = [compose_dir] + [d for d in extra_dirs if d] 119 | 120 | # Only allow these specific filenames 121 | valid_filenames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'] 122 | 123 | for search_dir in search_dirs: 124 | if not os.path.exists(search_dir): 125 | logger.warning(f"Scan directory doesn't exist: {search_dir}") 126 | continue 127 | 128 | for root, dirs, files in os.walk(search_dir, topdown=True): 129 | dirs[:] = [d for d in dirs if not d.startswith('.')] 130 | for file in files: 131 | # Only include files that match exactly our valid filenames 132 | if file in valid_filenames: 133 | file_path = os.path.join(root, file) 134 | try: 135 | relative_path = os.path.relpath(file_path, compose_dir) 136 | relative_path = relative_path.replace(os.sep, '/') 137 | compose_files.append(relative_path) 138 | logger.debug(f"Found compose file during scan: {relative_path}") 139 | except ValueError as e: 140 | logger.warning(f"Failed to compute relative path for {file_path}: {e}") 141 | continue 142 | 143 | logger.info(f"Total compose files found during scan: {len(compose_files)}") 144 | get_compose_files_cached.cache_clear() 145 | return sorted(compose_files) 146 | except Exception as e: 147 | logger.error(f"Failed to scan compose files: {e}", exc_info=True) 148 | raise 149 | 150 | def resolve_compose_file_path(file_path, compose_dir, extra_dirs, logger): 151 | """Resolve the full path of a compose file by checking configured directories""" 152 | logger.debug(f"Resolving compose file path: {file_path}") 153 | file_path = file_path.replace('\\', '/') 154 | if os.path.isabs(file_path): 155 | if os.path.exists(file_path): 156 | logger.debug(f"Found absolute path: {file_path}") 157 | return file_path 158 | try: 159 | relative_path = os.path.relpath(file_path, compose_dir) 160 | full_path = os.path.join(compose_dir, relative_path) 161 | if os.path.exists(full_path): 162 | logger.debug(f"Found file after converting absolute to relative: {full_path}") 163 | return full_path 164 | except ValueError: 165 | pass 166 | logger.debug(f"Absolute path does not exist: {file_path}") 167 | search_dirs = [compose_dir] + [d for d in extra_dirs if d] 168 | for search_dir in search_dirs: 169 | full_path = os.path.join(search_dir, file_path) 170 | if os.path.exists(full_path): 171 | logger.debug(f"Found file at: {full_path}") 172 | return full_path 173 | logger.debug(f"File not found at: {full_path}") 174 | logger.warning(f"Could not resolve compose file: {file_path}") 175 | return None 176 | 177 | def extract_env_from_compose(compose_file_path, modify_compose=False, logger=None): 178 | """Extract environment variables from a compose file to create a .env file""" 179 | try: 180 | with open(compose_file_path, 'r') as f: 181 | compose_data = yaml.safe_load(f) 182 | env_vars = {} 183 | compose_modified = False 184 | if compose_data and 'services' in compose_data: 185 | for service_name, service_config in compose_data['services'].items(): 186 | if 'environment' in service_config: 187 | env_section = service_config['environment'] 188 | if isinstance(env_section, list): 189 | for item in env_section: 190 | if isinstance(item, str) and '=' in item: 191 | key, value = item.split('=', 1) 192 | env_vars[key.strip()] = value.strip() 193 | if modify_compose: 194 | new_env = [key for key in env_vars.keys()] 195 | service_config['environment'] = new_env 196 | compose_modified = True 197 | elif isinstance(env_section, dict): 198 | for key, value in env_section.items(): 199 | if value is not None: 200 | env_vars[key.strip()] = str(value).strip() 201 | if modify_compose: 202 | new_env = {key: None for key in env_vars.keys()} 203 | service_config['environment'] = new_env 204 | compose_modified = True 205 | env_content = "# Auto-generated .env file from compose\n" 206 | env_content += "# Created: " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n\n" 207 | for key, value in env_vars.items(): 208 | env_content += f"{key}={value}\n" 209 | if modify_compose and compose_modified: 210 | with open(compose_file_path, 'w') as f: 211 | yaml.dump(compose_data, f, sort_keys=False) 212 | return env_content, compose_modified 213 | except Exception as e: 214 | if logger: 215 | logger.error(f"Failed to extract environment variables from compose file: {e}") 216 | return None, False 217 | 218 | def find_caddy_container(client, logger): 219 | """Find the Caddy container by looking for containers with caddy in the image name""" 220 | try: 221 | for container in client.containers.list(): 222 | if container.image.tags and any('caddy' in tag.lower() for tag in container.image.tags): 223 | return container 224 | return None 225 | except Exception as e: 226 | logger.error(f"Failed to find Caddy container: {e}") 227 | return None -------------------------------------------------------------------------------- /static/js/backup.js: -------------------------------------------------------------------------------- 1 | // Add these backup functions to your main.js or create a new backup.js file 2 | 3 | // Backup Management Functions 4 | let backupPreviewData = null; 5 | 6 | // Load backup preview when backup tab is opened 7 | function loadBackupPreview() { 8 | setLoading(true, 'Loading backup preview...'); 9 | 10 | fetch('/api/backup/preview', { 11 | method: 'POST', 12 | headers: { 'Content-Type': 'application/json' } 13 | }) 14 | .then(response => response.json()) 15 | .then(data => { 16 | setLoading(false); 17 | 18 | if (data.status === 'success') { 19 | backupPreviewData = data.preview; 20 | updateBackupPreview(data.preview); 21 | } else { 22 | showMessage('error', data.message || 'Failed to load backup preview'); 23 | } 24 | }) 25 | .catch(error => { 26 | setLoading(false); 27 | console.error('Failed to load backup preview:', error); 28 | showMessage('error', 'Failed to load backup preview'); 29 | }); 30 | } 31 | 32 | // Update the backup preview display 33 | function updateBackupPreview(preview) { 34 | const previewElement = document.getElementById('backup-preview'); 35 | if (!previewElement) return; 36 | 37 | previewElement.innerHTML = ` 38 |
39 |
40 | ${preview.compose_files} 41 | Compose 42 |
43 |
44 | ${preview.env_files} 45 | Env Files 46 |
47 |
48 | ${preview.containers} 49 | Containers 50 |
51 |
52 | `; 53 | } 54 | 55 | // Create a backup 56 | function createBackup() { 57 | const backupName = document.getElementById('backup-name').value.trim(); 58 | const includeEnvFiles = document.getElementById('include-env-files').checked; 59 | const includeComposeFiles = document.getElementById('include-compose-files').checked; 60 | 61 | if (!backupName) { 62 | showMessage('error', 'Please enter a backup name'); 63 | return; 64 | } 65 | 66 | // Validate backup name (no special characters that would break filenames) 67 | if (!/^[a-zA-Z0-9_-]+$/.test(backupName)) { 68 | showMessage('error', 'Backup name can only contain letters, numbers, underscores, and hyphens'); 69 | return; 70 | } 71 | 72 | const backupOptions = { 73 | backup_name: backupName, 74 | include_env_files: includeEnvFiles, 75 | include_compose_files: includeComposeFiles 76 | }; 77 | 78 | setLoading(true, 'Creating backup... This may take a moment.'); 79 | 80 | fetch('/api/backup/create', { 81 | method: 'POST', 82 | headers: { 'Content-Type': 'application/json' }, 83 | body: JSON.stringify(backupOptions) 84 | }) 85 | .then(response => { 86 | setLoading(false); 87 | 88 | if (response.ok) { 89 | // File download - create blob and download 90 | return response.blob().then(blob => { 91 | const url = window.URL.createObjectURL(blob); 92 | const a = document.createElement('a'); 93 | a.href = url; 94 | a.download = `${backupName}.zip`; 95 | document.body.appendChild(a); 96 | a.click(); 97 | window.URL.revokeObjectURL(url); 98 | document.body.removeChild(a); 99 | 100 | showMessage('success', `Backup created and downloaded: ${backupName}.zip`); 101 | 102 | // Reset form 103 | document.getElementById('backup-name').value = ''; 104 | 105 | // Add to backup history 106 | addToBackupHistory(backupName, new Date().toISOString()); 107 | }); 108 | } else { 109 | return response.json().then(data => { 110 | throw new Error(data.message || 'Failed to create backup'); 111 | }); 112 | } 113 | }) 114 | .catch(error => { 115 | setLoading(false); 116 | console.error('Failed to create backup:', error); 117 | showMessage('error', `Failed to create backup: ${error.message}`); 118 | }); 119 | } 120 | 121 | // Restore from backup file 122 | function restoreBackup() { 123 | const fileInput = document.getElementById('backup-file-input'); 124 | const file = fileInput.files[0]; 125 | 126 | if (!file) { 127 | showMessage('error', 'Please select a backup file'); 128 | return; 129 | } 130 | 131 | if (!file.name.endsWith('.zip')) { 132 | showMessage('error', 'Please select a valid backup file (.zip)'); 133 | return; 134 | } 135 | 136 | const formData = new FormData(); 137 | formData.append('backup_file', file); 138 | 139 | setLoading(true, 'Restoring backup... This may take a moment.'); 140 | 141 | fetch('/api/backup/restore', { 142 | method: 'POST', 143 | body: formData 144 | }) 145 | .then(response => response.json()) 146 | .then(result => { 147 | setLoading(false); 148 | 149 | if (result.status === 'success') { 150 | showBackupRestoreSuccess(result); 151 | 152 | // Clear file input 153 | fileInput.value = ''; 154 | 155 | // Refresh compose files and containers 156 | if (typeof loadComposeFiles === 'function') loadComposeFiles(); 157 | if (typeof refreshContainers === 'function') refreshContainers(); 158 | } else { 159 | showMessage('error', result.message || 'Failed to restore backup'); 160 | } 161 | }) 162 | .catch(error => { 163 | setLoading(false); 164 | console.error('Failed to restore backup:', error); 165 | showMessage('error', `Failed to restore backup: ${error.message}`); 166 | }); 167 | } 168 | 169 | // Show backup restore success modal 170 | function showBackupRestoreSuccess(result) { 171 | const modal = document.createElement('div'); 172 | modal.className = 'logs-modal'; 173 | modal.innerHTML = ` 174 | 178 | 215 | `; 216 | document.body.appendChild(modal); 217 | } 218 | 219 | // Backup history management (stored in localStorage) - simplified to just track last backup 220 | function addToBackupHistory(backupName, timestamp) { 221 | try { 222 | // Just store the last backup info 223 | const lastBackup = { 224 | name: backupName, 225 | created: timestamp 226 | }; 227 | 228 | localStorage.setItem('composr-last-backup', JSON.stringify(lastBackup)); 229 | updateBackupHistoryDisplay(); 230 | } catch (error) { 231 | console.error('Failed to update backup history:', error); 232 | } 233 | } 234 | 235 | function updateBackupHistoryDisplay() { 236 | const historyElement = document.getElementById('backup-history'); 237 | if (!historyElement) return; 238 | 239 | try { 240 | const lastBackup = JSON.parse(localStorage.getItem('composr-last-backup') || 'null'); 241 | 242 | if (!lastBackup) { 243 | historyElement.innerHTML = '

No backups created yet

'; 244 | return; 245 | } 246 | 247 | historyElement.innerHTML = ` 248 |

Last Backup

249 |
250 |
251 | ${lastBackup.name} 252 | ${new Date(lastBackup.created).toLocaleString()} 253 |
254 |
255 | `; 256 | } catch (error) { 257 | console.error('Failed to display backup history:', error); 258 | historyElement.innerHTML = '

Error loading backup history

'; 259 | } 260 | } 261 | 262 | // Initialize backup tab when it's first opened 263 | function initializeBackupTab() { 264 | // Set default backup name 265 | const now = new Date(); 266 | const defaultName = `backup-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; 267 | 268 | const backupNameInput = document.getElementById('backup-name'); 269 | if (backupNameInput && !backupNameInput.value) { 270 | backupNameInput.value = defaultName; 271 | } 272 | 273 | // Load preview and history 274 | loadBackupPreview(); 275 | updateBackupHistoryDisplay(); 276 | } 277 | 278 | // File input change handler 279 | function handleBackupFileSelect() { 280 | const fileInput = document.getElementById('backup-file-input'); 281 | const fileName = document.getElementById('selected-file-name'); 282 | 283 | if (fileInput.files.length > 0) { 284 | const file = fileInput.files[0]; 285 | fileName.textContent = file.name; 286 | fileName.style.display = 'block'; 287 | } else { 288 | fileName.style.display = 'none'; 289 | } 290 | } 291 | 292 | // Export backup functions to global scope 293 | window.createBackup = createBackup; 294 | window.restoreBackup = restoreBackup; 295 | window.loadBackupPreview = loadBackupPreview; 296 | window.initializeBackupTab = initializeBackupTab; 297 | window.handleBackupFileSelect = handleBackupFileSelect; 298 | window.addToBackupHistory = addToBackupHistory; 299 | window.updateBackupHistoryDisplay = updateBackupHistoryDisplay; -------------------------------------------------------------------------------- /static/css/editor.css: -------------------------------------------------------------------------------- 1 | /* CodeMirror Editor Specific Styles */ 2 | .codemirror-editor-container { 3 | width: 100%; 4 | height: 500px; 5 | border: 1px solid var(--border-color); 6 | border-radius: 0.5rem; 7 | overflow: hidden; 8 | } 9 | 10 | /* Force CodeMirror wrapper to proper height */ 11 | .CodeMirror { 12 | height: 500px !important; 13 | min-height: 400px !important; 14 | font-family: 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace !important; 15 | font-size: 14px !important; 16 | line-height: 1.5 !important; 17 | } 18 | 19 | /* Theme-specific CodeMirror adjustments */ 20 | [data-theme="refined-dark"] .codemirror-editor-container, 21 | [data-theme="minimal-dark"] .codemirror-editor-container, 22 | [data-theme="dracula"] .codemirror-editor-container, 23 | [data-theme="nord"] .codemirror-editor-container, 24 | [data-theme="solarized-dark"] .codemirror-editor-container, 25 | [data-theme="github-dark"] .codemirror-editor-container, 26 | [data-theme="atom-one-dark"] .codemirror-editor-container, 27 | [data-theme="monokai"] .codemirror-editor-container { 28 | background-color: var(--card-background); 29 | } 30 | 31 | /* Responsive CodeMirror editor */ 32 | @media (max-width: 768px) { 33 | .codemirror-editor-container { 34 | height: 400px; 35 | } 36 | 37 | .CodeMirror { 38 | height: 400px !important; 39 | min-height: 300px !important; 40 | } 41 | } 42 | 43 | @media (max-width: 480px) { 44 | .codemirror-editor-container { 45 | height: 300px; 46 | } 47 | 48 | .CodeMirror { 49 | height: 300px !important; 50 | min-height: 250px !important; 51 | } 52 | } 53 | 54 | /* Loading state for editors */ 55 | .codemirror-editor-loading { 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | height: 100%; 60 | color: var(--text-secondary); 61 | font-size: 1.2rem; 62 | } 63 | 64 | .codemirror-editor-loading::after { 65 | content: "Loading editor..."; 66 | } 67 | 68 | /* Error state for editors */ 69 | .codemirror-editor-error { 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | height: 100%; 74 | color: var(--accent-error); 75 | font-size: 1.2rem; 76 | } 77 | 78 | /* Editor action buttons styling */ 79 | .editor-actions { 80 | display: flex; 81 | gap: 0.5rem; 82 | } 83 | 84 | .editor-header { 85 | display: flex; 86 | justify-content: space-between; 87 | align-items: center; 88 | padding: 0.75rem; 89 | background-color: var(--card-header-bg); 90 | border-radius: 0.5rem 0.5rem 0 0; 91 | border-bottom: 1px solid var(--border-color); 92 | } 93 | 94 | /* Project Creation Form - Compact Version */ 95 | .project-creation-form { 96 | background: var(--bg-card); 97 | border-radius: 8px; 98 | padding: 1rem; 99 | margin-bottom: 1rem; 100 | } 101 | 102 | .form-title { 103 | margin-top: 0; 104 | margin-bottom: 0.5rem; 105 | font-size: 1.1rem; 106 | color: var(--text-primary); 107 | } 108 | 109 | .form-group { 110 | margin-bottom: 0.75rem; 111 | } 112 | 113 | .form-group label { 114 | display: block; 115 | margin-bottom: 0.15rem; 116 | color: var(--text-primary); 117 | font-weight: 500; 118 | font-size: 0.9rem; 119 | } 120 | 121 | .form-group input, 122 | .form-group select { 123 | width: 100%; 124 | padding: 0.5rem; 125 | border: 1px solid var(--border-color); 126 | border-radius: 6px; 127 | background: var(--bg-secondary); 128 | color: var(--text-primary); 129 | font-size: 0.85rem; 130 | } 131 | 132 | /* Make the compose textarea much bigger */ 133 | .form-group textarea { 134 | width: 100%; 135 | padding: 0.5rem; 136 | border: 1px solid var(--border-color); 137 | border-radius: 6px; 138 | background: var(--bg-secondary); 139 | color: var(--text-primary); 140 | font-size: 0.85rem; 141 | font-family: monospace; 142 | min-height: 400px; 143 | resize: vertical; 144 | } 145 | 146 | /* CodeMirror in create form should also be bigger */ 147 | .form-group .CodeMirror { 148 | height: 400px !important; 149 | min-height: 300px !important; 150 | border: 1px solid var(--border-color); 151 | border-radius: 6px; 152 | } 153 | 154 | .form-actions { 155 | display: flex; 156 | gap: 0.5rem; 157 | margin-top: 1rem; 158 | } 159 | 160 | .form-step { 161 | display: none; 162 | } 163 | 164 | .form-step.active { 165 | display: block; 166 | } 167 | 168 | .step-title { 169 | display: flex; 170 | align-items: center; 171 | margin-bottom: 1rem; 172 | } 173 | 174 | .step-number { 175 | display: flex; 176 | align-items: center; 177 | justify-content: center; 178 | width: 1.5rem; 179 | height: 1.5rem; 180 | border-radius: 50%; 181 | background: var(--accent-primary); 182 | color: white; 183 | font-weight: bold; 184 | font-size: 0.8rem; 185 | margin-right: 0.5rem; 186 | } 187 | 188 | .step-title h4 { 189 | margin: 0; 190 | font-size: 1rem; 191 | } 192 | 193 | .project-preview { 194 | background: var(--bg-secondary); 195 | border-radius: 8px; 196 | padding: 1rem; 197 | margin-top: 1rem; 198 | } 199 | 200 | .preview-file { 201 | margin-bottom: 1rem; 202 | } 203 | 204 | .preview-file h4 { 205 | margin-top: 0; 206 | margin-bottom: 0.5rem; 207 | display: flex; 208 | align-items: center; 209 | } 210 | 211 | .preview-file-icon { 212 | margin-right: 0.5rem; 213 | } 214 | 215 | .preview-content { 216 | font-family: monospace; 217 | font-size: 0.85rem; 218 | white-space: pre-wrap; 219 | background: var(--bg-primary); 220 | padding: 0.75rem; 221 | border-radius: 6px; 222 | max-height: 200px; 223 | overflow-y: auto; 224 | } 225 | 226 | /* Compact template info */ 227 | .template-info { 228 | font-size: 0.85rem; 229 | color: var(--text-secondary); 230 | background: var(--bg-tertiary); 231 | padding: 0.4rem 0.6rem; 232 | border-radius: 4px; 233 | border: 1px solid var(--border-color); 234 | } 235 | 236 | /* Make help text smaller */ 237 | .help-text { 238 | font-size: 0.8rem; 239 | color: var(--text-secondary); 240 | margin-top: 0.25rem; 241 | line-height: 1.3; 242 | } 243 | 244 | /* Checkbox styling */ 245 | input[type="checkbox"] { 246 | margin-right: 8px; 247 | vertical-align: middle; 248 | } 249 | 250 | label[for="env-content"] { 251 | display: flex; 252 | align-items: center; 253 | margin-bottom: 8px; 254 | } 255 | 256 | /* Two-column layout for smaller fields */ 257 | .form-row { 258 | display: grid; 259 | grid-template-columns: 1fr 1fr; 260 | gap: 1rem; 261 | } 262 | 263 | /* Editor Actions - Keep on one line */ 264 | .editor-actions { 265 | display: flex; 266 | align-items: center; 267 | gap: 0.5rem; 268 | flex-wrap: nowrap !important; 269 | margin-top: 1rem; 270 | } 271 | 272 | .editor-actions .form-group { 273 | display: flex; 274 | align-items: center; 275 | gap: 0.5rem; 276 | margin-bottom: 0; 277 | white-space: nowrap; 278 | flex-shrink: 0; 279 | height: 40px; 280 | } 281 | 282 | .editor-actions .form-group label { 283 | margin-bottom: 0 !important; 284 | height: auto; 285 | display: flex; 286 | align-items: center; 287 | } 288 | 289 | .editor-actions .filter-select { 290 | height: 40px !important; 291 | padding: 0.5rem; 292 | box-sizing: border-box; 293 | } 294 | 295 | .editor-actions .btn { 296 | height: 40px !important; 297 | padding: 0.5rem 1rem; 298 | box-sizing: border-box; 299 | display: flex; 300 | align-items: center; 301 | justify-content: center; 302 | } 303 | 304 | /* Fix that specific refresh button alignment */ 305 | #compose-subtab > div:nth-child(4) > div:nth-child(1) > button:nth-child(3) { 306 | margin-top: 3px; 307 | } 308 | 309 | /* Regular textarea editors (fallback) */ 310 | #compose-editor, 311 | #env-editor, 312 | #caddy-editor { 313 | width: 100%; 314 | height: 500px !important; 315 | min-height: 400px !important; 316 | font-family: monospace; 317 | padding: 0.75rem; 318 | border: 1px solid var(--border-color); 319 | border-radius: 8px; 320 | background: var(--bg-secondary); 321 | color: var(--text-primary); 322 | resize: vertical; 323 | } 324 | 325 | /* Mobile responsive adjustments */ 326 | @media (max-width: 768px) { 327 | .form-row { 328 | grid-template-columns: 1fr; 329 | gap: 0.5rem; 330 | } 331 | 332 | .form-group textarea, 333 | .form-group .CodeMirror { 334 | min-height: 300px !important; 335 | height: 300px !important; 336 | } 337 | 338 | .project-creation-form { 339 | padding: 0.75rem; 340 | } 341 | 342 | /* Regular editors on mobile */ 343 | #compose-editor, 344 | #env-editor, 345 | #caddy-editor { 346 | height: 400px !important; 347 | min-height: 300px !important; 348 | } 349 | 350 | /* Editor actions responsive */ 351 | .editor-actions { 352 | flex-direction: row !important; 353 | flex-wrap: wrap !important; 354 | gap: 0.5rem !important; 355 | } 356 | 357 | .editor-actions .form-group { 358 | flex-direction: row !important; 359 | align-items: center !important; 360 | } 361 | 362 | .editor-actions .btn { 363 | width: auto !important; 364 | flex-shrink: 0; 365 | } 366 | } 367 | 368 | /* Mobile header and navigation fixes */ 369 | @media (max-width: 768px) { 370 | header { 371 | padding: 0.5rem; 372 | gap: 0.5rem; 373 | display: flex; 374 | align-items: center; 375 | justify-content: space-between; 376 | } 377 | 378 | .system-stats { 379 | padding: 8px; 380 | } 381 | 382 | .stats-compact { 383 | display: flex; 384 | flex-direction: column; 385 | align-items: flex-end; 386 | font-size: 0.75rem; 387 | gap: 2px; 388 | } 389 | 390 | .stats-compact span { 391 | white-space: nowrap; 392 | } 393 | 394 | .stats-grid { 395 | display: none; 396 | } 397 | 398 | .tabs { 399 | display: flex; 400 | background-color: var(--background-secondary); 401 | border-radius: 8px 8px 0 0; 402 | overflow-x: auto; 403 | scrollbar-width: none; 404 | -ms-overflow-style: none; 405 | padding: 0; 406 | width: 100%; 407 | } 408 | 409 | .tabs::-webkit-scrollbar { 410 | display: none; 411 | } 412 | 413 | .tab { 414 | padding: 0.5rem 0.75rem; 415 | font-size: 0.85rem; 416 | white-space: nowrap; 417 | flex-shrink: 0; 418 | min-width: auto; 419 | } 420 | 421 | .tab-icon { 422 | font-size: 1rem; 423 | margin-right: 0.5rem; 424 | } 425 | 426 | .tab-text { 427 | font-size: 0.8rem; 428 | } 429 | 430 | .config-subtabs { 431 | max-width: none; 432 | width: 100%; 433 | flex-wrap: wrap; 434 | gap: 0.25rem; 435 | padding: 0.5rem; 436 | } 437 | 438 | .subtab { 439 | padding: 0.4rem 0.75rem; 440 | font-size: 0.8rem; 441 | flex: 1; 442 | min-width: auto; 443 | text-align: center; 444 | } 445 | 446 | .compose-file-selector { 447 | flex-direction: column; 448 | gap: 0.75rem; 449 | align-items: stretch; 450 | } 451 | 452 | .compose-file-selector select { 453 | width: 100%; 454 | margin-bottom: 0.5rem; 455 | } 456 | 457 | .scan-btn { 458 | width: 100%; 459 | padding: 0.75rem; 460 | } 461 | 462 | .tab-content { 463 | padding: 0.75rem 0.5rem; 464 | } 465 | } 466 | 467 | /* Very small screens */ 468 | @media (max-width: 480px) { 469 | .tab { 470 | padding: 0.4rem 0.6rem; 471 | font-size: 0.8rem; 472 | } 473 | 474 | .tab-icon { 475 | font-size: 0.9rem; 476 | margin-right: 0.25rem; 477 | } 478 | 479 | .tab-text { 480 | font-size: 0.75rem; 481 | } 482 | } -------------------------------------------------------------------------------- /remote_hosts.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import logging 3 | import threading 4 | import time 5 | import json 6 | import os 7 | from datetime import datetime, timezone 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class HostManager: 12 | def __init__(self, metadata_dir=None): 13 | if metadata_dir is None: 14 | metadata_dir = os.environ.get('METADATA_DIR', '/app/data') 15 | 16 | self.clients = {} 17 | self.host_configs = {} 18 | self.connection_status = {} 19 | self.last_health_check = {} 20 | self.current_host = 'local' 21 | self.metadata_dir = metadata_dir 22 | self.hosts_file = os.path.join(metadata_dir, 'docker_hosts.json') 23 | self._lock = threading.Lock() 24 | 25 | # Initialize with local Docker 26 | self._initialize_local_docker() 27 | 28 | # Load saved hosts 29 | self._load_hosts_from_file() 30 | 31 | # Start health check thread 32 | self._start_health_checker() 33 | 34 | def _initialize_local_docker(self): 35 | """Initialize local Docker connection""" 36 | try: 37 | # Try different socket paths 38 | socket_paths = [ 39 | 'unix:///var/run/docker.sock', 40 | 'unix:///run/docker.sock' 41 | ] 42 | 43 | local_client = None 44 | for socket_path in socket_paths: 45 | try: 46 | local_client = docker.DockerClient(base_url=socket_path, timeout=10) 47 | local_client.ping() 48 | logger.info(f"Connected to local Docker at {socket_path}") 49 | break 50 | except Exception as e: 51 | logger.debug(f"Failed to connect to {socket_path}: {e}") 52 | continue 53 | 54 | if local_client: 55 | self.clients['local'] = local_client 56 | self.host_configs['local'] = { 57 | 'type': 'local', 58 | 'url': socket_paths[0], 59 | 'name': 'Local Docker', 60 | 'added_at': datetime.now(timezone.utc).isoformat() 61 | } 62 | self.connection_status['local'] = True 63 | self.last_health_check['local'] = time.time() 64 | else: 65 | logger.error("Failed to connect to local Docker") 66 | raise Exception("Could not connect to local Docker daemon") 67 | 68 | except Exception as e: 69 | logger.error(f"Error initializing local Docker: {e}") 70 | raise 71 | 72 | def _load_hosts_from_file(self): 73 | """Load host configurations from file""" 74 | try: 75 | if os.path.exists(self.hosts_file): 76 | with open(self.hosts_file, 'r') as f: 77 | saved_hosts = json.load(f) 78 | 79 | for host_name, config in saved_hosts.items(): 80 | if host_name != 'local': # Don't override local config 81 | self.host_configs[host_name] = config 82 | # Try to connect to saved hosts 83 | if self._test_connection(config): 84 | self._create_client(host_name, config) 85 | else: 86 | self.connection_status[host_name] = False 87 | logger.warning(f"Saved host {host_name} is not reachable") 88 | except Exception as e: 89 | logger.error(f"Error loading hosts from file: {e}") 90 | 91 | def _save_hosts_to_file(self): 92 | """Save host configurations to file""" 93 | try: 94 | # Only save non-local hosts 95 | hosts_to_save = { 96 | name: config for name, config in self.host_configs.items() 97 | if name != 'local' 98 | } 99 | 100 | with open(self.hosts_file, 'w') as f: 101 | json.dump(hosts_to_save, f, indent=2) 102 | except Exception as e: 103 | logger.error(f"Error saving hosts to file: {e}") 104 | 105 | def add_host(self, name, url, description=None): 106 | """Add and test new Docker host connection""" 107 | with self._lock: 108 | if name in self.host_configs: 109 | return False, f"Host {name} already exists" 110 | 111 | config = { 112 | 'type': 'tcp', 113 | 'url': url, 114 | 'name': description or name, 115 | 'added_at': datetime.now(timezone.utc).isoformat() 116 | } 117 | 118 | if self._test_connection(config): 119 | if self._create_client(name, config): 120 | self.host_configs[name] = config 121 | self.connection_status[name] = True 122 | self.last_health_check[name] = time.time() 123 | self._save_hosts_to_file() 124 | logger.info(f"Successfully added host {name}") 125 | return True, f"Host {name} added successfully" 126 | else: 127 | return False, f"Failed to create client for {name}" 128 | else: 129 | return False, f"Could not connect to {name} at {url}" 130 | 131 | def remove_host(self, name): 132 | """Remove a Docker host""" 133 | with self._lock: 134 | if name == 'local': 135 | return False, "Cannot remove local host" 136 | 137 | if name not in self.host_configs: 138 | return False, f"Host {name} not found" 139 | 140 | # Close client connection 141 | if name in self.clients: 142 | try: 143 | self.clients[name].close() 144 | except: 145 | pass 146 | del self.clients[name] 147 | 148 | # Remove from configs 149 | del self.host_configs[name] 150 | if name in self.connection_status: 151 | del self.connection_status[name] 152 | if name in self.last_health_check: 153 | del self.last_health_check[name] 154 | 155 | # Switch to local if this was current host 156 | if self.current_host == name: 157 | self.current_host = 'local' 158 | 159 | self._save_hosts_to_file() 160 | logger.info(f"Removed host {name}") 161 | return True, f"Host {name} removed successfully" 162 | 163 | def switch_host(self, host_name): 164 | """Switch current host context""" 165 | if host_name not in self.clients: 166 | raise Exception(f"Host {host_name} not available") 167 | 168 | if not self.connection_status.get(host_name, False): 169 | raise Exception(f"Host {host_name} is not connected") 170 | 171 | self.current_host = host_name 172 | logger.info(f"Switched to host {host_name}") 173 | return self.clients[host_name] 174 | 175 | def get_client(self, host_name=None): 176 | """Get Docker client for specific host or current host""" 177 | target_host = host_name or self.current_host 178 | 179 | if target_host not in self.clients: 180 | logger.error(f"Host {target_host} not found in clients") 181 | return None # Don't fall back, return None 182 | 183 | if not self.connection_status.get(target_host, False): 184 | logger.error(f"Host {target_host} not connected") 185 | return None # Don't fall back, return None 186 | 187 | return self.clients[target_host] 188 | 189 | def get_all_containers(self): 190 | """Get containers from all connected hosts""" 191 | all_containers = [] 192 | 193 | for host_name, client in self.clients.items(): 194 | if self.connection_status.get(host_name, False): 195 | try: 196 | containers = client.containers.list(all=True) 197 | for container in containers: 198 | # Add host identifier to each container 199 | container._host = host_name 200 | all_containers.extend(containers) 201 | except Exception as e: 202 | logger.error(f"Failed to get containers from host {host_name}: {e}") 203 | self.connection_status[host_name] = False 204 | 205 | return all_containers 206 | 207 | def get_hosts_status(self): 208 | """Get status of all hosts""" 209 | status = {} 210 | for host_name in self.host_configs: 211 | config = self.host_configs[host_name] 212 | status[host_name] = { 213 | 'name': config.get('name', host_name), 214 | 'url': config.get('url', ''), 215 | 'type': config.get('type', 'unknown'), 216 | 'connected': self.connection_status.get(host_name, False), 217 | 'last_check': self.last_health_check.get(host_name, 0), 218 | 'current': host_name == self.current_host 219 | } 220 | return status 221 | 222 | def get_connected_hosts(self): 223 | """Get list of currently connected host names""" 224 | return [ 225 | name for name, status in self.connection_status.items() 226 | if status 227 | ] 228 | 229 | def test_host_connection(self, url): 230 | """Test connection to a Docker host without adding it""" 231 | config = {'url': url, 'type': 'tcp'} 232 | return self._test_connection(config) 233 | 234 | def _test_connection(self, config): 235 | """Test Docker host connectivity""" 236 | try: 237 | test_client = docker.DockerClient(base_url=config['url'], timeout=5) 238 | test_client.ping() 239 | test_client.close() 240 | return True 241 | except Exception as e: 242 | logger.debug(f"Connection test failed for {config['url']}: {e}") 243 | return False 244 | 245 | def _create_client(self, host_name, config): 246 | """Create and store Docker client""" 247 | try: 248 | client = docker.DockerClient(base_url=config['url'], timeout=10) 249 | client.ping() # Verify connection 250 | self.clients[host_name] = client 251 | return True 252 | except Exception as e: 253 | logger.error(f"Failed to create client for {host_name}: {e}") 254 | return False 255 | 256 | def _start_health_checker(self): 257 | """Start background health check thread""" 258 | def health_check_worker(): 259 | while True: 260 | try: 261 | self._perform_health_check() 262 | except Exception as e: 263 | logger.error(f"Health check error: {e}") 264 | time.sleep(30) # Check every 30 seconds 265 | 266 | health_thread = threading.Thread(target=health_check_worker, daemon=True) 267 | health_thread.start() 268 | logger.info("Started health check thread") 269 | 270 | def _perform_health_check(self): 271 | """Check health of all host connections""" 272 | current_time = time.time() 273 | 274 | for host_name, config in self.host_configs.items(): 275 | try: 276 | if host_name in self.clients: 277 | # Test existing connection 278 | self.clients[host_name].ping() 279 | self.connection_status[host_name] = True 280 | self.last_health_check[host_name] = current_time 281 | else: 282 | # Try to reconnect 283 | if self._test_connection(config): 284 | if self._create_client(host_name, config): 285 | self.connection_status[host_name] = True 286 | self.last_health_check[host_name] = current_time 287 | logger.info(f"Reconnected to host {host_name}") 288 | else: 289 | self.connection_status[host_name] = False 290 | except Exception as e: 291 | logger.warning(f"Health check failed for {host_name}: {e}") 292 | self.connection_status[host_name] = False 293 | 294 | # Try to reconnect 295 | if host_name in self.clients: 296 | try: 297 | self.clients[host_name].close() 298 | except: 299 | pass 300 | del self.clients[host_name] 301 | 302 | # Global instance 303 | host_manager = HostManager() -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to Composr will be documented in this file. 3 | ## [1.7.7] - 2025-07-01 4 | ### Added 5 | - **🎯 Smart Health Indicators**: Intelligent container health assessment system 6 | - Real-time health evaluation based on container status, uptime, and resource usage 7 | - Visual health indicators with color-coded status (green=healthy, yellow=warning, red=error) 8 | - Smart warning detection for recently restarted containers (< 5 minutes uptime) 9 | - Health tooltips showing specific issues and recommendations 10 | - **📊 Persistent Operation Results**: Enhanced operation feedback system 11 | - Detailed operation result modals showing actual docker-compose command output 12 | - Persistent error messages that stay visible until manually dismissed 13 | - Success messages with auto-close after 10 seconds 14 | - Full command output display for debugging deployment failures 15 | - Network error handling with detailed error information 16 | - **🔒 Scroll Position Preservation**: Automatic scroll position retention 17 | - Container list maintains scroll position after refresh operations 18 | - No more jumping back to top after container actions or page updates 19 | - Improved user experience for large container lists 20 | 21 | ### Changed 22 | - **Container Status Display**: Replaced basic status badges with health-aware color coding 23 | - Container status text now changes color based on health level 24 | - Removed separate health column for cleaner table layout 25 | - Unified health indication across both card and table views 26 | - **Card Layout Redesign**: Modernized container card appearance and organization 27 | - 4-row compact layout: Name → Host/Status/Uptime/More → Ports → Actions 28 | - Reduced card height (240px → 120px) for better screen utilization 29 | - Modern gradient backgrounds with improved hover effects 30 | - Better content organization with proper spacing and alignment 31 | - **Table Structure Optimization**: Streamlined table columns for better usability 32 | - Consolidated health and status into single color-coded status column 33 | - Improved column alignment and responsive design 34 | - Fixed group header colspan to match new column structure 35 | 36 | ### Fixed 37 | - **Operation Feedback**: Replaced generic "success/failed" messages with actual command output 38 | - Users now see real docker-compose errors instead of "operation failed" 39 | - Full deployment logs visible for troubleshooting 40 | - Network errors properly captured and displayed 41 | - **Card View Consistency**: Fixed container card sizing and layout inconsistencies 42 | - All cards now maintain uniform height regardless of content 43 | - Proper content overflow handling for long port lists 44 | - Consistent button placement and spacing 45 | - **Health Indicator Integration**: Seamless health status across all views 46 | - Card view health dots properly positioned in top-right corner 47 | - Table view health coloring applied consistently 48 | - Health assessment working for both grouped and ungrouped views 49 | - **Page Navigation**: Fixed scroll position jumping to top after container operations 50 | - Maintains user's current scroll position during refresh operations 51 | - Better UX for managing large numbers of containers 52 | 53 | ### Technical 54 | - **Health Assessment Engine**: Comprehensive container health evaluation system 55 | - **Operation Result Modal**: New modal system for displaying command outputs 56 | - **Scroll Management**: Intelligent scroll position preservation system 57 | - **CSS Optimization**: Streamlined styling with improved responsiveness 58 | - **Error Handling**: Enhanced error capture and display throughout the application 59 | 60 | This release significantly improves user experience by providing clear visual feedback about container health, detailed information when operations fail, and seamless navigation that maintains user context during operations. 61 | ## [1.7.6] - 2025-06-20 62 | - **Fixed critical bug where container labels were lost during backup/restore** 63 | - **All original container labels (watchtower, traefik, custom, etc.) are now 64 | - **changed default updates logic to include verion # 65 | ## [1.7.5] - 2025-06-20 66 | - **Fixed critical bug where Docker hosts were not persisting across container restarts** 67 | - **Fixed HostManager to properly use METADATA_DIR environment variable** 68 | 69 | 70 | ## [1.7.4] - 2025-06-20 71 | - **Removed cached host data from image build** 72 | - **Removed instance selector - deprecated** 73 | - **Increased editor window size** 74 | ## [1.7.2] - 2025-06-18 75 | ### Fixed 76 | - **UI Consistency**: Fixed button alignment and spacing issues across all themes 77 | - **Mobile Layout**: Improved container card layouts on mobile devices 78 | - **Theme Switching**: Resolved dark mode toggle inconsistencies in navigation 79 | - **Table Responsiveness**: Fixed column alignment issues in container table view 80 | - **Modal Positioning**: Improved modal centering and backdrop behavior 81 | - **Typography**: Standardized font sizes and weights across interface elements 82 | 83 | ### Changed 84 | - **Visual Polish**: Enhanced visual consistency with refined spacing and borders 85 | - **Loading States**: Improved loading indicators and transitions 86 | - **Color Scheme**: Fine-tuned color contrasts for better accessibility 87 | - **Icon Consistency**: Standardized icon usage throughout the interface 88 | 89 | ## [1.7.1] - 2025-06-15 90 | ### Added 91 | - **🔄 Automatic Container Update System**: Complete container update management 92 | - Smart version detection with semantic versioning support 93 | - Docker Hub API integration for latest version checking 94 | - Auto-safe updates for patch versions only (e.g., 1.2.3 → 1.2.4) 95 | - Scheduled repulls for latest/stable tags 96 | - Configurable update intervals and exclusion patterns 97 | - Automatic backup creation before updates 98 | - Rollback support for failed updates 99 | - **Update Management Interface**: Dedicated update settings and control panel 100 | - Batch update operations across multiple containers 101 | - Individual container update with version selection 102 | - Update preview and dry-run capabilities 103 | - Comprehensive exclusion system (tags, images, containers) 104 | - **Multi-Host Update Support**: Update management across all connected Docker hosts 105 | - Host-aware update routing and status tracking 106 | - Unified update interface for all hosts 107 | - Per-host update statistics and monitoring 108 | 109 | ### Changed 110 | - **Enhanced Container Monitoring**: Improved container status detection for updates 111 | - **API Extensions**: New endpoints for update checking and management 112 | - **Performance Optimization**: Reduced API calls through intelligent caching 113 | 114 | ### Security 115 | - **Update Safety**: Multiple safety layers to prevent accidental breaking changes 116 | - **Backup Integration**: Automatic backups before any update operations 117 | - **Permission Validation**: Enhanced Docker permission checking for update operations 118 | 119 | ⚠️ **Note**: Container update system is experimental. Test thoroughly before using in production. 120 | 121 | ## [1.7.0] - 2025-06-10 122 | ### Added 123 | - **🌐 Multi-Host Docker Management**: Complete multi-host support 124 | - Centralized control of multiple Docker hosts from single interface 125 | - Remote Docker host connections via TCP (e.g., tcp://192.168.1.100:2375) 126 | - Cross-host container deployment and management 127 | - Unified container view with host badges and filtering 128 | - Per-host system statistics and monitoring 129 | - Host connection status tracking and management 130 | - **Host Management Interface**: Dedicated hosts configuration panel 131 | - Add/remove Docker hosts with connection testing 132 | - Host discovery and automatic configuration 133 | - Real-time connection status monitoring 134 | - Individual host details and Docker version info 135 | - **Cross-Host Operations**: All container operations work across hosts 136 | - Start/stop/restart containers on any connected host 137 | - View logs and execute commands in remote containers 138 | - Deploy compose projects to specific hosts 139 | - Batch operations across multiple hosts simultaneously 140 | - **Enhanced Project Creation**: Multi-host deployment support 141 | - Choose target host during project creation 142 | - Cross-host project deployment validation 143 | - Host-specific deployment feedback and error handling 144 | 145 | ### Changed 146 | - **Container Interface**: Added host identification badges to all containers 147 | - **Filtering System**: Enhanced filtering with host-based grouping options 148 | - **Navigation**: Updated interface to accommodate multi-host features 149 | - **API Architecture**: Redesigned API to support multiple Docker connections 150 | 151 | ### Technical 152 | - **Connection Management**: Robust Docker connection handling and failover 153 | - **Error Handling**: Improved error reporting for multi-host operations 154 | - **Performance**: Optimized multi-host data fetching and caching 155 | - **Security**: Enhanced validation for remote Docker connections 156 | 157 | ### Migration 158 | - **Backward Compatibility**: Existing single-host setups continue to work unchanged 159 | - **Configuration**: Optional DOCKER_HOSTS environment variable for multi-host setup 160 | - **Data Migration**: Automatic migration of existing container metadata 161 | ## [1.6.1] - 2025-06-01 162 | Changed 163 | 164 | Container Display: Replaced CPU/Memory stats with port mappings in main container view 165 | Container cards now show exposed ports (e.g., "8080:80, 443:443") instead of resource usage 166 | Table view has single "Ports" column instead of separate CPU/Memory columns 167 | CPU and Memory stats moved to detailed container popup for better organization 168 | "No ports" displayed for containers without exposed ports 169 | 170 | Fixed 171 | 172 | Table View Controls: Fixed button placement and filter synchronization issues 173 | Toggle view button now appears in correct column (Ports, not Actions) 174 | Group By filter now works properly in table view 175 | Improved bidirectional sync between table and grid view filters 176 | 177 | Technical 178 | 179 | Enhanced container data fetching to include port information via inspection API 180 | Updated table column structure from 10 to 8 columns 181 | Added responsive CSS styling for port display across all themes 182 | Maintained backward compatibility with existing sorting and filtering 183 | 184 | 185 | ## [1.6.0] - 2025-05-25 186 | ### Added 187 | - **Project Creation Tool**: New "Create" subtab with step-by-step project wizard 188 | - Template-based project creation with environment variable extraction 189 | - Support for multiple project locations (main directory + extra directories) 190 | - Create & Deploy functionality with intelligent error handling 191 | - Automatic .env file generation from compose templates 192 | - **Backup & Restore System**: Complete configuration backup and restore 193 | - One-click backup creation with downloadable ZIP archives 194 | - Unified backup compose files for easy deployment 195 | - Container metadata preservation (tags, custom URLs, stack assignments) 196 | - Automated restore scripts included in backup archives 197 | - Backup history tracking with local storage 198 | - **Enhanced Environment Variable Management**: 199 | - Extract variables from compose files in both Create and Compose tabs 200 | - Create new .env files directly from extracted variables 201 | - Improved environment file editor with better mobile support 202 | 203 | ### Changed 204 | - **Editor Migration**: Switched from Monaco Editor to CodeMirror 5 205 | - Reduced Docker image size significantly 206 | - Improved loading performance and stability 207 | - Maintained syntax highlighting for YAML, shell, and JavaScript 208 | - Better mobile editor experience with responsive heights 209 | - **Docker Image Optimization**: Multi-stage build implementation 210 | - Switched to Alpine Linux base for smaller footprint 211 | - Multi-stage build separates build dependencies from runtime 212 | - Multi-architecture build support (AMD64, ARM64, ARMv7) 213 | - Automated version management in build pipeline 214 | - Significantly reduced final image size while maintaining full functionality 215 | - **Mobile Interface Improvements**: 216 | - Fixed Config tab layout issues with better button stacking 217 | - Forced Images tab to card view on mobile (removed confusing table view) 218 | - Improved header layout and tab navigation on mobile devices 219 | - Better modal positioning and sizing for mobile screens 220 | - **Create & Deploy Workflow Enhancement**: 221 | - Intelligent partial success handling (project created but deployment failed) 222 | - Detailed error modals with retry options and file editing access 223 | - Better user feedback throughout the creation process 224 | 225 | ### Fixed 226 | - **Mobile Layout Issues**: 227 | - Config subtabs now wrap properly on mobile screens 228 | - Images tab displays correctly as cards instead of table format 229 | - System stats header maintains proper alignment on mobile 230 | - All navigation tabs visible and properly sized for mobile devices 231 | - **Project Creation Edge Cases**: 232 | - Fixed environment file creation in both create-only and create-deploy scenarios 233 | - Proper handling of project location selection (extra directories vs main directory) 234 | - Form state management when switching between tabs 235 | - **Editor Improvements**: 236 | - Better CodeMirror initialization timing 237 | - Improved content synchronization between editors and forms 238 | - Fixed mobile editor height and responsiveness issues 239 | 240 | ## [1.5.0] - 2025-05-19 241 | ### Added 242 | - Instance Bookmarks feature - easily switch between different Composr instances 243 | - Improved user interface with consistent styling across themes 244 | - Dropdown menu for quick switching between bookmarked instances 245 | - Server-side bookmark storage for reliability across browsers 246 | 247 | ### Changed 248 | - Simplified multi-host approach to use bookmarks instead of direct connections 249 | - Updated README with clearer installation instructions for different platforms 250 | - Improved dropdown menu styling in dark themes 251 | - Refined UI elements for better consistency 252 | 253 | ### Fixed 254 | - ARM platform detection for Raspberry Pi and other ARM devices 255 | - Docker image building for multi-architecture support 256 | - Toggle view button styling issues 257 | - Dropdown menu background colors in dark themes 258 | 259 | ## [1.4.1] - 2025-05-15 260 | ### Added 261 | - multi-host support* 262 | *Multi-host management is still in development. The Agent connection type is recommended for production use as it's more secure than exposing Docker directly. 263 | Important: limited or no support is available for connection types other than the Composr Agent. For best results and future compatibility, use the Agent connection method. Even it is still untested 264 | 265 | **Components** 266 | - **Main Application**: Web UI and API for Docker management 267 | - **Composr Agent**: Lightweight API-only component for remote hosts 268 | 269 | - Monaco Editor for improved code editing experience 270 | - Syntax highlighting for YAML, INI, and Caddyfile 271 | - Theme-aware editor that switches with app theme 272 | - Debug mode toggle via DEBUG environment variable 273 | 274 | ### Changed 275 | - Improved editor height for desktop displays (600px default, 700px on large screens) 276 | - Moved log files to persist in metadata directory 277 | - Switched to Gunicorn for production deployment 278 | 279 | ### Fixed 280 | - Production deployment warnings 281 | 282 | ## [1.4] - 2025-05 283 | ### Added 284 | - Previous features... -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Combined build and release script for Composr with GitHub Release support 3 | # Get version from command line 4 | VERSION=${1} 5 | 6 | # Check if version was provided 7 | if [ -z "$VERSION" ]; then 8 | echo "Usage: ./release.sh " 9 | echo "Example: ./release.sh 1.6.0" 10 | echo "" 11 | echo "This will:" 12 | echo " 1. Handle any merge conflicts automatically" 13 | echo " 2. Create git commit and tag (if possible)" 14 | echo " 3. Push to GitHub (if possible)" 15 | echo " 4. Create GitHub release (if GitHub CLI is available)" 16 | echo " 5. Build multi-arch Docker image with cleanup" 17 | echo " 6. Push to Docker Hub" 18 | echo " Note: Docker build will continue even if GitHub operations fail" 19 | exit 1 20 | fi 21 | 22 | echo "🚀 Starting Composr release process for version: $VERSION" 23 | echo "=========================================================" 24 | 25 | # Git operations flag 26 | GIT_SUCCESS=true 27 | GITHUB_RELEASE_SUCCESS=false 28 | 29 | # Check for GitHub CLI availability 30 | GH_CLI_AVAILABLE=false 31 | if command -v gh &> /dev/null; then 32 | echo "✅ GitHub CLI found - will create GitHub release" 33 | GH_CLI_AVAILABLE=true 34 | else 35 | echo "⚠️ GitHub CLI not found - will skip GitHub release creation" 36 | echo " Install with: brew install gh (macOS) or apt install gh (Ubuntu)" 37 | fi 38 | 39 | # Step 1: Git operations with conflict handling 40 | echo "" 41 | echo "📝 Step 1: Git operations with conflict resolution" 42 | echo "-------------------------------------------------" 43 | 44 | # Check if we're in a git repository 45 | if ! git rev-parse --git-dir > /dev/null 2>&1; then 46 | echo "⚠️ Warning: Not in a git repository - skipping git operations" 47 | GIT_SUCCESS=false 48 | else 49 | # Handle potential conflicts by fetching and merging first 50 | echo "🔄 Fetching latest changes from GitHub..." 51 | if ! git fetch origin; then 52 | echo "⚠️ Warning: Failed to fetch from GitHub - continuing with local changes" 53 | GIT_SUCCESS=false 54 | else 55 | # Get current branch 56 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 57 | if [ "$BRANCH" != "main" ]; then 58 | echo "⚠️ Warning: You're on branch '$BRANCH'" 59 | echo " Consider switching to 'main' branch for releases" 60 | fi 61 | echo "Current branch: $BRANCH" 62 | 63 | # Try to merge any remote changes 64 | echo "🔀 Checking for remote changes..." 65 | if ! git merge origin/$BRANCH --no-edit; then 66 | echo "⚠️ Merge conflicts detected!" 67 | echo "📝 Auto-resolving common conflicts..." 68 | 69 | # Auto-resolve README conflicts by preferring local version 70 | if git status --porcelain | grep -q "README.md"; then 71 | echo " - README.md conflict: using local version" 72 | git checkout --ours README.md 73 | git add README.md 74 | fi 75 | 76 | # Auto-resolve VERSION conflicts by using the new version 77 | if git status --porcelain | grep -q "VERSION"; then 78 | echo " - VERSION conflict: using new version ($VERSION)" 79 | echo "$VERSION" > VERSION 80 | git add VERSION 81 | fi 82 | 83 | # Check if all conflicts are resolved 84 | if git status --porcelain | grep -q "^UU\|^AA\|^DD"; then 85 | echo "⚠️ Some conflicts still need manual resolution - skipping git operations" 86 | echo "Unresolved conflicts:" 87 | git status --porcelain | grep "^UU\|^AA\|^DD" 88 | GIT_SUCCESS=false 89 | else 90 | # Complete the merge 91 | git commit --no-edit -m "Auto-resolved merge conflicts for release $VERSION" 92 | echo "✅ Conflicts resolved automatically" 93 | fi 94 | fi 95 | fi 96 | fi 97 | 98 | # Write version to VERSION file (always do this) 99 | echo "" 100 | echo "📝 Updating version files..." 101 | echo "Updating VERSION file to $VERSION" 102 | echo "$VERSION" > VERSION 103 | 104 | # Update version in app.py if it exists 105 | if [ -f "app.py" ]; then 106 | echo "Updating version in app.py" 107 | sed -i "s/__version__ = \".*\"/__version__ = \"$VERSION\"/" app.py 108 | fi 109 | 110 | # Function to generate release notes 111 | generate_release_notes() { 112 | local version=$1 113 | local release_notes="" 114 | 115 | # Check for CHANGELOG.md or RELEASES.md 116 | if [ -f "CHANGELOG.md" ]; then 117 | echo "📋 Found CHANGELOG.md - extracting release notes..." 118 | # Extract notes for this version from CHANGELOG 119 | # Handle both [1.7.0] and ## 1.7.0 formats 120 | release_notes=$(awk " 121 | /## \[?v?${version//./\\.}\]?( -|$)/ { 122 | found=1; 123 | next 124 | } 125 | found && /## \[?v?[0-9]/ && !/## \[?v?${version//./\\.}\]?/ { 126 | exit 127 | } 128 | found { 129 | print 130 | }" CHANGELOG.md | sed '/^[[:space:]]*$/d') 131 | 132 | if [ -n "$release_notes" ]; then 133 | echo "✅ Using changelog entries for v$version" 134 | fi 135 | elif [ -f "RELEASES.md" ]; then 136 | echo "📋 Found RELEASES.md - extracting release notes..." 137 | release_notes=$(awk "/## v?${version//./\\.}/,/## v?[0-9]/ {if (/## v?[0-9]/ && !/## v?${version//./\\.}/) exit; if (!/## v?${version//./\\.}/) print}" RELEASES.md | sed '/^$/d') 138 | fi 139 | 140 | # If no specific changelog found, generate from git commits 141 | if [ -z "$release_notes" ] && [ "$GIT_SUCCESS" = true ]; then 142 | echo "📋 No changelog found - generating from git commits..." 143 | 144 | # Get the previous tag 145 | PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null | head -1) 146 | 147 | if [ -n "$PREV_TAG" ]; then 148 | echo "Generating changes since $PREV_TAG..." 149 | 150 | # Get commits since last tag, format them nicely 151 | COMMITS=$(git log --oneline --pretty=format:"- %s" "$PREV_TAG..HEAD" 2>/dev/null) 152 | 153 | if [ -n "$COMMITS" ]; then 154 | release_notes="### 🆕 Changes in this release: 155 | $COMMITS" 156 | fi 157 | else 158 | echo "No previous tags found - using recent commits..." 159 | COMMITS=$(git log --oneline --pretty=format:"- %s" -10 2>/dev/null) 160 | if [ -n "$COMMITS" ]; then 161 | release_notes="### 🆕 Recent changes: 162 | $COMMITS" 163 | fi 164 | fi 165 | fi 166 | 167 | # Build the full release notes 168 | FULL_RELEASE_NOTES="## Composr v$version 169 | 170 | 🚀 **Container Management Platform**" 171 | 172 | # Add changelog/commit info if available 173 | if [ -n "$release_notes" ]; then 174 | FULL_RELEASE_NOTES="$FULL_RELEASE_NOTES 175 | 176 | $release_notes" 177 | fi 178 | 179 | # Add standard sections 180 | FULL_RELEASE_NOTES="$FULL_RELEASE_NOTES 181 | 182 | ### 🐳 Docker Images 183 | - \`docker pull vansmak/composr:$version\` 184 | - \`docker pull vansmak/composr:latest\` 185 | 186 | ### 🔧 Supported Platforms 187 | - linux/amd64 188 | - linux/arm64 189 | - linux/arm/v7 190 | 191 | ### 📦 Installation 192 | \`\`\`bash 193 | docker run -d \\ 194 | --name composr \\ 195 | -p 5003:5003 \\ 196 | -v /var/run/docker.sock:/var/run/docker.sock \\ 197 | -v /path/to/docker/projects:/app/projects \\ 198 | -v /path/to/config/composr:/app/data \\ 199 | vansmak/composr:$version 200 | \`\`\` 201 | 202 | ### ✨ Core Features 203 | - Multi-host Docker container management 204 | - Real-time container monitoring and control 205 | - Docker Compose file editor with syntax highlighting 206 | - Environment file management 207 | - Image management across multiple hosts 208 | - Backup and restore functionality 209 | - Modern web interface with dark/light themes" 210 | 211 | echo "$FULL_RELEASE_NOTES" 212 | } 213 | 214 | # Continue with git operations if successful so far 215 | if [ "$GIT_SUCCESS" = true ]; then 216 | # Add files 217 | echo "Adding files to git..." 218 | git add . 219 | git add -A 220 | 221 | # Check if there are changes to commit 222 | if git diff --staged --quiet; then 223 | echo "No changes to commit" 224 | else 225 | echo "Creating release commit..." 226 | if git commit -m "Composr v$VERSION 227 | 228 | ✨ Features: 229 | - Multi-host Docker container management 230 | - Real-time container monitoring and control 231 | - Docker Compose file editor with syntax highlighting 232 | - Environment file management 233 | - Image management across multiple hosts 234 | - Backup and restore functionality 235 | - Modern web interface with dark/light themes"; then 236 | echo "✅ Commit created successfully" 237 | else 238 | echo "⚠️ Warning: Failed to create commit - continuing anyway" 239 | GIT_SUCCESS=false 240 | fi 241 | fi 242 | 243 | # Create tag 244 | if [ "$GIT_SUCCESS" = true ]; then 245 | echo "Creating release tag v$VERSION..." 246 | if git tag -a "v$VERSION" -m "Composr v$VERSION 247 | 248 | 🚀 Container Management Platform 249 | - Multi-host Docker container management 250 | - Real-time monitoring and control 251 | - Compose file editing with live preview 252 | - Environment variable management 253 | - Cross-platform image management 254 | - Backup/restore functionality 255 | - Modern responsive web interface"; then 256 | echo "✅ Tag created successfully" 257 | else 258 | echo "⚠️ Warning: Failed to create tag - continuing anyway" 259 | GIT_SUCCESS=false 260 | fi 261 | fi 262 | 263 | # Push commits and tags 264 | if [ "$GIT_SUCCESS" = true ]; then 265 | echo "📤 Pushing to GitHub..." 266 | if git push origin $BRANCH && git push origin "v$VERSION"; then 267 | echo "✅ Git operations completed successfully" 268 | else 269 | echo "⚠️ Warning: Failed to push to GitHub - continuing with Docker build" 270 | GIT_SUCCESS=false 271 | fi 272 | fi 273 | fi 274 | 275 | if [ "$GIT_SUCCESS" = false ]; then 276 | echo "" 277 | echo "⚠️ Git operations had issues, but continuing with Docker build..." 278 | echo " You can manually resolve git issues later if needed." 279 | fi 280 | 281 | # Step 2: Create GitHub Release (if possible) 282 | if [ "$GIT_SUCCESS" = true ] && [ "$GH_CLI_AVAILABLE" = true ]; then 283 | echo "" 284 | echo "📋 Step 2: Creating GitHub Release" 285 | echo "--------------------------------" 286 | 287 | # Check if user is authenticated with GitHub CLI 288 | if gh auth status &> /dev/null; then 289 | # Generate release notes dynamically 290 | RELEASE_NOTES=$(generate_release_notes "$VERSION") 291 | 292 | # Create the release 293 | if echo "$RELEASE_NOTES" | gh release create "v$VERSION" \ 294 | --title "Composr v$VERSION" \ 295 | --notes-file - \ 296 | --latest; then 297 | echo "✅ GitHub release created successfully!" 298 | echo " View at: https://github.com/$(gh repo view --json owner,name -q '.owner.login + "/" + .name')/releases/tag/v$VERSION" 299 | GITHUB_RELEASE_SUCCESS=true 300 | else 301 | echo "⚠️ Warning: Failed to create GitHub release" 302 | fi 303 | else 304 | echo "⚠️ GitHub CLI not authenticated - skipping release creation" 305 | echo " Run 'gh auth login' to authenticate" 306 | fi 307 | else 308 | if [ "$GIT_SUCCESS" = false ]; then 309 | echo "" 310 | echo "⚠️ Skipping GitHub release creation due to git operation failures" 311 | elif [ "$GH_CLI_AVAILABLE" = false ]; then 312 | echo "" 313 | echo "⚠️ GitHub CLI not available - skipping GitHub release creation" 314 | fi 315 | fi 316 | 317 | # Step 3: Docker build and push with cleanup (ALWAYS RUNS) 318 | echo "" 319 | echo "🐳 Step 3: Docker build and push with automatic cleanup" 320 | echo "------------------------------------------------------" 321 | 322 | # Create temporary builder with unique name 323 | BUILDER_NAME="composr-builder-$$" 324 | echo "🔧 Creating temporary buildx builder: $BUILDER_NAME" 325 | 326 | # Cleanup function 327 | cleanup_buildx() { 328 | echo "" 329 | echo "🧹 Cleaning up buildx environment..." 330 | echo "Removing temporary builder: $BUILDER_NAME" 331 | docker buildx rm $BUILDER_NAME 2>/dev/null || true 332 | 333 | # Clean up any orphaned buildx containers 334 | echo "Cleaning up orphaned buildx containers..." 335 | docker container prune -f --filter "label=com.docker.compose.project=buildx" 2>/dev/null || true 336 | 337 | # Remove any containers with builder/buildkit in the name 338 | echo "Removing any remaining builder containers..." 339 | docker ps -aq --filter "name=builder" | xargs -r docker rm -f 2>/dev/null || true 340 | docker ps -aq --filter "name=buildkit" | xargs -r docker rm -f 2>/dev/null || true 341 | 342 | # Clean up buildx cache 343 | echo "Pruning buildx cache..." 344 | docker buildx prune -f 2>/dev/null || true 345 | 346 | echo "✅ Buildx cleanup completed - no more annoying containers!" 347 | } 348 | 349 | # Set trap for cleanup 350 | trap cleanup_buildx EXIT INT TERM 351 | 352 | # Create temporary builder 353 | if ! docker buildx create --name $BUILDER_NAME --use; then 354 | echo "❌ Failed to create buildx builder" 355 | exit 1 356 | fi 357 | 358 | # Ensure the builder is running 359 | echo "Bootstrapping builder..." 360 | if ! docker buildx inspect $BUILDER_NAME --bootstrap; then 361 | echo "❌ Failed to bootstrap builder" 362 | exit 1 363 | fi 364 | 365 | echo "" 366 | echo "🏗️ Building Composr multi-arch image for version: $VERSION" 367 | echo "Platforms: linux/amd64, linux/arm64, linux/arm/v7" 368 | 369 | # Build and push 370 | if docker buildx build \ 371 | --builder $BUILDER_NAME \ 372 | --platform linux/amd64,linux/arm64,linux/arm/v7 \ 373 | -t vansmak/composr:$VERSION \ 374 | -t vansmak/composr:latest \ 375 | --push \ 376 | .; then 377 | 378 | echo "" 379 | echo "🎉 Docker build completed successfully!" 380 | echo "=====================================" 381 | echo "✅ Docker images built and pushed:" 382 | echo " - vansmak/composr:$VERSION" 383 | echo " - vansmak/composr:latest" 384 | 385 | else 386 | echo "" 387 | echo "❌ Docker build failed!" 388 | exit 1 389 | fi 390 | 391 | # Summary 392 | echo "" 393 | echo "🎉 Release Summary" 394 | echo "==================" 395 | echo "Version: $VERSION" 396 | if [ "$GIT_SUCCESS" = true ]; then 397 | echo "Git operations: ✅ SUCCESS" 398 | echo "Git tag: v$VERSION" 399 | echo "Git branch: $BRANCH" 400 | else 401 | echo "Git operations: ⚠️ SKIPPED/FAILED" 402 | echo "Note: You may need to manually handle git operations" 403 | fi 404 | 405 | if [ "$GITHUB_RELEASE_SUCCESS" = true ]; then 406 | echo "GitHub release: ✅ SUCCESS" 407 | echo "Release URL: https://github.com/$(gh repo view --json owner,name -q '.owner.login + "/" + .name')/releases/tag/v$VERSION" 408 | else 409 | echo "GitHub release: ⚠️ SKIPPED/FAILED" 410 | fi 411 | 412 | echo "Docker operations: ✅ SUCCESS" 413 | echo "Docker images:" 414 | echo " - vansmak/composr:$VERSION" 415 | echo " - vansmak/composr:latest" 416 | 417 | echo "" 418 | echo "🚀 Composr v$VERSION build completed!" 419 | echo "" 420 | echo "Next steps:" 421 | if [ "$GIT_SUCCESS" = true ]; then 422 | echo " - ✅ Check GitHub: https://github.com/vansmak/composr" 423 | if [ "$GITHUB_RELEASE_SUCCESS" = true ]; then 424 | echo " - ✅ GitHub release created automatically" 425 | else 426 | echo " - ⚠️ Create GitHub release manually if needed" 427 | fi 428 | else 429 | echo " - ⚠️ Manually push to GitHub if needed:" 430 | echo " git add . && git commit -m 'Release v$VERSION'" 431 | echo " git tag v$VERSION && git push origin main --tags" 432 | fi 433 | echo " - ✅ Check Docker Hub: https://hub.docker.com/r/vansmak/composr" 434 | echo " - ✅ Test with: docker pull vansmak/composr:$VERSION" 435 | echo " - 📝 Update documentation if needed" 436 | 437 | echo "" 438 | echo "🔧 Composr Features:" 439 | echo " - Multi-host container management" 440 | echo " - Real-time monitoring and control" 441 | echo " - Compose file editing with live syntax" 442 | echo " - Environment variable management" 443 | echo " - Multi-platform Docker image support" 444 | echo "" 445 | echo "🧹 Buildx cleanup will complete automatically..." 446 | # Cleanup happens via trap -------------------------------------------------------------------------------- /static/css/updates.css: -------------------------------------------------------------------------------- 1 | /* Container Update Management Styles - Add to your styles.css */ 2 | 3 | /* Container Update Badge in Header */ 4 | .container-update-badge { 5 | background: linear-gradient(135deg, #28a745, #20c997); 6 | color: white; 7 | padding: 0.4rem 0.8rem; 8 | border-radius: 1.5rem; 9 | font-size: 0.8rem; 10 | font-weight: 600; 11 | cursor: pointer; 12 | transition: all 0.3s ease; 13 | margin-left: 1rem; 14 | display: flex; 15 | align-items: center; 16 | gap: 0.3rem; 17 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 18 | animation: pulse-update 3s infinite; 19 | } 20 | 21 | .container-update-badge:hover { 22 | transform: translateY(-1px); 23 | box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); 24 | } 25 | 26 | .container-update-badge .update-badge-icon { 27 | animation: bounce 2s infinite; 28 | } 29 | 30 | @keyframes pulse-update { 31 | 0%, 100% { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } 32 | 50% { box-shadow: 0 2px 15px rgba(40, 167, 69, 0.4); } 33 | } 34 | 35 | @keyframes bounce { 36 | 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 37 | 40% { transform: translateY(-3px); } 38 | 60% { transform: translateY(-1px); } 39 | } 40 | 41 | /* Update Indicator on Container Cards */ 42 | .update-indicator { 43 | position: absolute; 44 | top: 0.5rem; 45 | right: 0.5rem; 46 | background: linear-gradient(135deg, #28a745, #20c997); 47 | color: white; 48 | padding: 0.2rem 0.5rem; 49 | border-radius: 1rem; 50 | font-size: 0.7rem; 51 | font-weight: 600; 52 | cursor: pointer; 53 | transition: all 0.2s ease; 54 | z-index: 10; 55 | display: flex; 56 | align-items: center; 57 | gap: 0.2rem; 58 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 59 | } 60 | 61 | .update-indicator:hover { 62 | transform: scale(1.05); 63 | box-shadow: 0 3px 10px rgba(40, 167, 69, 0.4); 64 | } 65 | 66 | .update-indicator .update-icon { 67 | animation: rotate 2s linear infinite; 68 | font-size: 0.8rem; 69 | } 70 | 71 | .update-indicator .update-text { 72 | white-space: nowrap; 73 | } 74 | 75 | /* Container Updates Modal */ 76 | .container-updates-modal .modal-content { 77 | max-width: 800px; 78 | width: 95vw; 79 | max-height: 90vh; 80 | overflow-y: auto; 81 | } 82 | 83 | .container-updates-content { 84 | padding: 1.5rem; 85 | } 86 | 87 | .update-summary { 88 | background: var(--bg-secondary); 89 | padding: 1.5rem; 90 | border-radius: 0.5rem; 91 | margin-bottom: 1.5rem; 92 | border: 1px solid var(--border-color); 93 | } 94 | 95 | .summary-stats { 96 | display: grid; 97 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 98 | gap: 1rem; 99 | margin-bottom: 1.5rem; 100 | } 101 | 102 | .stat-item { 103 | text-align: center; 104 | padding: 1rem; 105 | background: var(--bg-card); 106 | border-radius: 0.25rem; 107 | border: 1px solid var(--border-color); 108 | } 109 | 110 | .stat-item.success { 111 | border-left: 4px solid var(--accent-success); 112 | } 113 | 114 | .stat-item.error { 115 | border-left: 4px solid var(--accent-error); 116 | } 117 | 118 | .stat-value { 119 | display: block; 120 | font-size: 2rem; 121 | font-weight: bold; 122 | color: var(--accent-primary); 123 | } 124 | 125 | .stat-label { 126 | font-size: 0.85rem; 127 | color: var(--text-secondary); 128 | margin-top: 0.25rem; 129 | } 130 | 131 | .update-actions { 132 | display: flex; 133 | gap: 0.5rem; 134 | flex-wrap: wrap; 135 | justify-content: center; 136 | } 137 | 138 | /* Updates Sections */ 139 | .updates-section { 140 | margin-bottom: 2rem; 141 | } 142 | 143 | .updates-section h4 { 144 | margin: 0 0 1rem 0; 145 | color: var(--text-primary); 146 | padding-bottom: 0.5rem; 147 | border-bottom: 2px solid var(--border-color); 148 | } 149 | 150 | .updates-list, 151 | .up-to-date-list, 152 | .errors-list { 153 | background: var(--bg-card); 154 | border-radius: 0.5rem; 155 | border: 1px solid var(--border-color); 156 | overflow: hidden; 157 | } 158 | 159 | .update-item { 160 | display: flex; 161 | justify-content: space-between; 162 | align-items: center; 163 | padding: 1rem; 164 | border-bottom: 1px solid var(--border-color); 165 | transition: background-color 0.2s ease; 166 | } 167 | 168 | .update-item:last-child { 169 | border-bottom: none; 170 | } 171 | 172 | .update-item:hover { 173 | background: var(--bg-hover); 174 | } 175 | 176 | .update-item.up-to-date { 177 | opacity: 0.8; 178 | } 179 | 180 | .update-item.error { 181 | background: var(--bg-error-subtle); 182 | border-left: 4px solid var(--accent-error); 183 | } 184 | 185 | .update-item-info { 186 | flex: 1; 187 | } 188 | 189 | .update-item-info strong { 190 | display: block; 191 | margin-bottom: 0.25rem; 192 | color: var(--text-primary); 193 | } 194 | 195 | .version-info { 196 | display: flex; 197 | align-items: center; 198 | gap: 0.5rem; 199 | margin-top: 0.25rem; 200 | } 201 | 202 | .current-version { 203 | background: var(--bg-tertiary); 204 | padding: 0.1rem 0.4rem; 205 | border-radius: 0.2rem; 206 | font-size: 0.8rem; 207 | color: var(--text-secondary); 208 | } 209 | 210 | .new-version { 211 | background: var(--accent-success); 212 | color: white; 213 | padding: 0.1rem 0.4rem; 214 | border-radius: 0.2rem; 215 | font-size: 0.8rem; 216 | font-weight: 600; 217 | } 218 | 219 | .version-arrow { 220 | color: var(--accent-primary); 221 | font-weight: bold; 222 | } 223 | 224 | .error-message { 225 | color: var(--accent-error); 226 | font-size: 0.85rem; 227 | margin-top: 0.25rem; 228 | } 229 | 230 | .update-item-actions { 231 | margin-left: 1rem; 232 | } 233 | 234 | .more-items { 235 | padding: 0.75rem 1rem; 236 | text-align: center; 237 | color: var(--text-secondary); 238 | font-style: italic; 239 | background: var(--bg-tertiary); 240 | } 241 | 242 | /* Individual Container Update Modal */ 243 | .container-update-modal .modal-content { 244 | max-width: 600px; 245 | width: 90vw; 246 | } 247 | 248 | .container-update-info { 249 | padding: 1.5rem; 250 | } 251 | 252 | .container-update-info h4 { 253 | margin: 0 0 1rem 0; 254 | color: var(--text-primary); 255 | } 256 | 257 | .version-update-info { 258 | display: flex; 259 | align-items: center; 260 | justify-content: center; 261 | gap: 2rem; 262 | margin: 2rem 0; 263 | padding: 1.5rem; 264 | background: var(--bg-secondary); 265 | border-radius: 0.5rem; 266 | border: 1px solid var(--border-color); 267 | } 268 | 269 | .current-version-info, 270 | .new-version-info { 271 | text-align: center; 272 | } 273 | 274 | .new-version-info select { 275 | margin-top: 0.5rem; 276 | padding: 0.4rem; 277 | border: 1px solid var(--border-color); 278 | border-radius: 0.25rem; 279 | background: var(--bg-primary); 280 | color: var(--text-primary); 281 | min-width: 150px; 282 | } 283 | 284 | .update-options { 285 | margin: 1.5rem 0; 286 | padding: 1rem; 287 | background: var(--bg-tertiary); 288 | border-radius: 0.25rem; 289 | border: 1px solid var(--border-color); 290 | } 291 | 292 | .update-info { 293 | margin: 1rem 0; 294 | padding: 1rem; 295 | background: var(--bg-secondary); 296 | border-radius: 0.25rem; 297 | border: 1px solid var(--border-color); 298 | } 299 | 300 | .update-info p { 301 | margin: 0.5rem 0; 302 | color: var(--text-secondary); 303 | } 304 | 305 | .update-warning { 306 | margin: 1.5rem 0; 307 | padding: 1rem; 308 | background: var(--bg-warning-subtle); 309 | border: 1px solid var(--accent-warning); 310 | border-radius: 0.25rem; 311 | color: var(--text-primary); 312 | } 313 | 314 | .update-warning strong { 315 | color: var(--accent-warning); 316 | } 317 | 318 | /* Batch Update Modal */ 319 | .batch-update-modal .modal-content { 320 | max-width: 700px; 321 | width: 95vw; 322 | max-height: 90vh; 323 | overflow-y: auto; 324 | } 325 | 326 | .batch-update-info { 327 | padding: 1.5rem; 328 | } 329 | 330 | .batch-update-list { 331 | max-height: 400px; 332 | overflow-y: auto; 333 | background: var(--bg-card); 334 | border: 1px solid var(--border-color); 335 | border-radius: 0.5rem; 336 | margin: 1rem 0; 337 | } 338 | 339 | .batch-update-item { 340 | padding: 1rem; 341 | border-bottom: 1px solid var(--border-color); 342 | } 343 | 344 | .batch-update-item:last-child { 345 | border-bottom: none; 346 | } 347 | 348 | .batch-update-item .checkbox-label { 349 | display: flex !important; 350 | align-items: flex-start; 351 | gap: 0.75rem; 352 | margin: 0 !important; 353 | cursor: pointer; 354 | } 355 | 356 | .batch-update-item input[type="checkbox"] { 357 | margin-top: 0.2rem; 358 | flex-shrink: 0; 359 | } 360 | 361 | .batch-item-info { 362 | flex: 1; 363 | } 364 | 365 | .batch-item-info strong { 366 | display: block; 367 | margin-bottom: 0.25rem; 368 | } 369 | 370 | .version-change { 371 | font-size: 0.85rem; 372 | color: var(--text-secondary); 373 | margin-top: 0.25rem; 374 | } 375 | 376 | .batch-options { 377 | margin: 1.5rem 0; 378 | padding: 1rem; 379 | background: var(--bg-secondary); 380 | border-radius: 0.25rem; 381 | border: 1px solid var(--border-color); 382 | } 383 | 384 | .batch-options .checkbox-label { 385 | margin-bottom: 0.5rem !important; 386 | } 387 | 388 | .batch-warning { 389 | margin: 1rem 0; 390 | padding: 1rem; 391 | background: var(--bg-warning-subtle); 392 | border: 1px solid var(--accent-warning); 393 | border-radius: 0.25rem; 394 | } 395 | 396 | /* Batch Results Modal */ 397 | .batch-results-modal .modal-content { 398 | max-width: 600px; 399 | width: 90vw; 400 | } 401 | 402 | .results-summary { 403 | margin-bottom: 1.5rem; 404 | } 405 | 406 | .results-section { 407 | margin-bottom: 1.5rem; 408 | } 409 | 410 | .results-section h4 { 411 | margin: 0 0 1rem 0; 412 | padding-bottom: 0.5rem; 413 | border-bottom: 2px solid var(--border-color); 414 | } 415 | 416 | .results-list { 417 | background: var(--bg-card); 418 | border-radius: 0.5rem; 419 | border: 1px solid var(--border-color); 420 | overflow: hidden; 421 | } 422 | 423 | .result-item { 424 | padding: 1rem; 425 | border-bottom: 1px solid var(--border-color); 426 | } 427 | 428 | .result-item:last-child { 429 | border-bottom: none; 430 | } 431 | 432 | .result-item.success { 433 | border-left: 4px solid var(--accent-success); 434 | } 435 | 436 | .result-item.error { 437 | border-left: 4px solid var(--accent-error); 438 | } 439 | 440 | .result-item strong { 441 | display: block; 442 | margin-bottom: 0.25rem; 443 | } 444 | 445 | .result-message { 446 | font-size: 0.85rem; 447 | color: var(--text-secondary); 448 | margin-top: 0.25rem; 449 | } 450 | 451 | .result-message.error { 452 | color: var(--accent-error); 453 | } 454 | 455 | /* Container Update Settings Modal */ 456 | .container-update-settings-modal .modal-content { 457 | max-width: 600px; 458 | width: 90vw; 459 | } 460 | 461 | .container-update-settings-modal .form-group { 462 | margin-bottom: 1.5rem; 463 | } 464 | 465 | .container-update-settings-modal label { 466 | display: block; 467 | margin-bottom: 0.5rem; 468 | font-weight: 500; 469 | color: var(--text-primary); 470 | } 471 | 472 | .container-update-settings-modal input[type="number"], 473 | .container-update-settings-modal input[type="text"] { 474 | width: 100%; 475 | padding: 0.5rem; 476 | border: 1px solid var(--border-color); 477 | border-radius: 0.25rem; 478 | background: var(--bg-primary); 479 | color: var(--text-primary); 480 | } 481 | 482 | .container-update-settings-modal small { 483 | display: block; 484 | margin-top: 0.25rem; 485 | color: var(--text-secondary); 486 | font-size: 0.8rem; 487 | } 488 | 489 | .container-update-settings-modal .update-info { 490 | margin-top: 1.5rem; 491 | padding: 1rem; 492 | background: var(--bg-tertiary); 493 | border-radius: 0.25rem; 494 | border: 1px solid var(--border-color); 495 | } 496 | 497 | .container-update-settings-modal .update-info h4 { 498 | margin: 0 0 0.5rem 0; 499 | color: var(--accent-primary); 500 | } 501 | 502 | .container-update-settings-modal .update-info ul { 503 | margin: 0.5rem 0; 504 | padding-left: 1.5rem; 505 | } 506 | 507 | .container-update-settings-modal .update-info li { 508 | margin: 0.25rem 0; 509 | color: var(--text-secondary); 510 | } 511 | 512 | /* Responsive Design */ 513 | @media (max-width: 768px) { 514 | .container-update-badge { 515 | margin-left: 0.5rem; 516 | padding: 0.3rem 0.6rem; 517 | font-size: 0.7rem; 518 | } 519 | 520 | .container-update-badge .update-badge-text { 521 | display: none; 522 | } 523 | 524 | .update-indicator { 525 | position: static; 526 | margin: 0.5rem 0; 527 | align-self: flex-start; 528 | } 529 | 530 | .update-indicator .update-text { 531 | display: none; 532 | } 533 | 534 | .version-update-info { 535 | flex-direction: column; 536 | gap: 1rem; 537 | text-align: center; 538 | } 539 | 540 | .version-arrow { 541 | transform: rotate(90deg); 542 | } 543 | 544 | .summary-stats { 545 | grid-template-columns: repeat(2, 1fr); 546 | } 547 | 548 | .update-actions { 549 | justify-content: stretch; 550 | } 551 | 552 | .update-actions button { 553 | flex: 1; 554 | } 555 | 556 | .update-item { 557 | flex-direction: column; 558 | align-items: flex-start; 559 | gap: 1rem; 560 | } 561 | 562 | .update-item-actions { 563 | margin-left: 0; 564 | align-self: stretch; 565 | } 566 | 567 | .version-info { 568 | flex-wrap: wrap; 569 | } 570 | 571 | .batch-update-item .checkbox-label { 572 | flex-direction: column; 573 | gap: 0.5rem; 574 | } 575 | } 576 | 577 | /* Dark theme adjustments */ 578 | [data-theme="refined-dark"] .container-update-badge, 579 | [data-theme="night-owl"] .container-update-badge, 580 | [data-theme="nord"] .container-update-badge { 581 | box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1); 582 | } 583 | 584 | [data-theme="refined-dark"] .container-update-badge:hover, 585 | [data-theme="night-owl"] .container-update-badge:hover, 586 | [data-theme="nord"] .container-update-badge:hover { 587 | box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3); 588 | } 589 | 590 | [data-theme="refined-dark"] .update-indicator, 591 | [data-theme="night-owl"] .update-indicator, 592 | [data-theme="nord"] .update-indicator { 593 | box-shadow: 0 2px 6px rgba(255, 255, 255, 0.1); 594 | } 595 | 596 | /* Light theme adjustments */ 597 | [data-theme="light-breeze"] .container-updates-modal { 598 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); 599 | } 600 | 601 | [data-theme="light-breeze"] .update-indicator { 602 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); 603 | } 604 | 605 | /* Animation for container cards with updates */ 606 | .container-card:has(.update-indicator) { 607 | animation: subtle-glow 4s infinite; 608 | } 609 | 610 | @keyframes subtle-glow { 611 | 0%, 100% { 612 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 613 | } 614 | 50% { 615 | box-shadow: 0 2px 12px rgba(40, 167, 69, 0.2); 616 | } 617 | } 618 | 619 | /* Loading states */ 620 | .update-checking { 621 | opacity: 0.7; 622 | pointer-events: none; 623 | } 624 | 625 | .update-checking::after { 626 | content: ''; 627 | position: absolute; 628 | top: 50%; 629 | left: 50%; 630 | width: 20px; 631 | height: 20px; 632 | margin: -10px 0 0 -10px; 633 | border: 2px solid var(--accent-primary); 634 | border-radius: 50%; 635 | border-top-color: transparent; 636 | animation: spin 1s linear infinite; 637 | } 638 | 639 | @keyframes spin { 640 | to { transform: rotate(360deg); } 641 | } 642 | 643 | /* Update status indicators */ 644 | .update-status-unknown { 645 | color: var(--text-secondary); 646 | } 647 | 648 | .update-status-available { 649 | color: var(--accent-success); 650 | font-weight: 600; 651 | } 652 | 653 | .update-status-uptodate { 654 | color: var(--accent-info); 655 | } 656 | 657 | .update-status-error { 658 | color: var(--accent-error); 659 | } 660 | 661 | /* Custom scrollbar for long lists */ 662 | .updates-list::-webkit-scrollbar, 663 | .batch-update-list::-webkit-scrollbar, 664 | .results-list::-webkit-scrollbar { 665 | width: 8px; 666 | } 667 | 668 | .updates-list::-webkit-scrollbar-track, 669 | .batch-update-list::-webkit-scrollbar-track, 670 | .results-list::-webkit-scrollbar-track { 671 | background: var(--bg-tertiary); 672 | border-radius: 4px; 673 | } 674 | 675 | .updates-list::-webkit-scrollbar-thumb, 676 | .batch-update-list::-webkit-scrollbar-thumb, 677 | .results-list::-webkit-scrollbar-thumb { 678 | background: var(--border-color); 679 | border-radius: 4px; 680 | } 681 | 682 | .updates-list::-webkit-scrollbar-thumb:hover, 683 | .batch-update-list::-webkit-scrollbar-thumb:hover, 684 | .results-list::-webkit-scrollbar-thumb:hover { 685 | background: var(--accent-primary); 686 | } 687 | 688 | /* Progress indicators for batch operations */ 689 | .batch-progress { 690 | margin: 1rem 0; 691 | padding: 1rem; 692 | background: var(--bg-secondary); 693 | border-radius: 0.25rem; 694 | border: 1px solid var(--border-color); 695 | } 696 | 697 | .batch-progress-bar { 698 | width: 100%; 699 | height: 8px; 700 | background: var(--bg-tertiary); 701 | border-radius: 4px; 702 | overflow: hidden; 703 | margin: 0.5rem 0; 704 | } 705 | 706 | .batch-progress-fill { 707 | height: 100%; 708 | background: linear-gradient(90deg, var(--accent-success), var(--accent-primary)); 709 | transition: width 0.3s ease; 710 | border-radius: 4px; 711 | } 712 | 713 | .batch-progress-text { 714 | font-size: 0.85rem; 715 | color: var(--text-secondary); 716 | text-align: center; 717 | } 718 | 719 | /* Tooltip styles for update information */ 720 | .update-tooltip { 721 | position: relative; 722 | cursor: help; 723 | } 724 | 725 | .update-tooltip::before { 726 | content: attr(data-tooltip); 727 | position: absolute; 728 | bottom: 125%; 729 | left: 50%; 730 | transform: translateX(-50%); 731 | background: var(--bg-tooltip); 732 | color: var(--text-tooltip); 733 | padding: 0.5rem; 734 | border-radius: 0.25rem; 735 | font-size: 0.8rem; 736 | white-space: nowrap; 737 | opacity: 0; 738 | pointer-events: none; 739 | transition: opacity 0.2s ease; 740 | z-index: 1000; 741 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 742 | } 743 | 744 | .update-tooltip:hover::before { 745 | opacity: 1; 746 | } 747 | 748 | /* Success and error state animations */ 749 | .update-success { 750 | animation: success-pulse 0.6s ease-out; 751 | } 752 | 753 | .update-error { 754 | animation: error-shake 0.6s ease-out; 755 | } 756 | 757 | @keyframes success-pulse { 758 | 0% { transform: scale(1); } 759 | 50% { transform: scale(1.05); background-color: var(--accent-success); } 760 | 100% { transform: scale(1); } 761 | } 762 | 763 | @keyframes error-shake { 764 | 0%, 100% { transform: translateX(0); } 765 | 25% { transform: translateX(-5px); } 766 | 75% { transform: translateX(5px); } 767 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Composr Logo](https://github.com/user-attachments/assets/1266525a-c298-4abb-b86a-b8afdd57bcdb) 2 | 3 | # Composr - Docker Compose Companion 4 | 5 | [![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/vansmak) 6 | 7 | A web-based interface for managing Docker containers and docker-compose configurations across multiple Docker hosts with powerful project creation and backup capabilities. 8 | Created using AI - use at your own risk. 9 | 10 | This project started as scratching my own itch - I wanted more intuitive Docker container management and couldn't find exactly what I wanted. I'm not a programmer by trade, but I had a clear vision for the solution I needed. 11 | I used AI as a development tool to help implement my ideas faster, just like any other tool. The creativity, problem-solving, architecture decisions, and feature design are all mine - AI helped with code, syntax and implementation details. Although I run everything in my own production environment first, it is catered to my environment and is use at your own risk. All code is open source for anyone to review and audit. 12 | The tool has been useful for me, and I shared it in case others can benefit from it too - but I absolutely understand if some prefer to stick with established alternatives. 13 | 14 | ## Key Features 15 | 16 | ### Multi-Host Docker Management 🆕 17 | - **Centralized Control**: Manage multiple Docker hosts from a single Composr interface 18 | - **Host Discovery**: Connect to remote Docker hosts via TCP connections 19 | - **Cross-Host Deployment**: Deploy compose projects to any connected Docker host 20 | - **Unified Container View**: See containers from all hosts in one interface with host badges 21 | - **Per-Host Filtering**: Filter and group containers by host for easy organization 22 | - **Host Status Monitoring**: Real-time connection status and system stats for each host 23 | 24 | ### Container Management 25 | - View, start, stop, restart, and delete containers across all connected hosts 26 | - Real-time logs viewing and container inspection 27 | - Resource usage stats (CPU, memory, uptime) aggregated across hosts 28 | - Container tagging and custom launch URLs 29 | - Built-in terminal for executing commands within containers on any host 30 | - **Cross-Host Operations**: Perform batch operations across multiple hosts 31 | 32 | ### Docker Compose Integration 33 | - **Project Creation Wizard**: Step-by-step tool for creating new Docker Compose projects 34 | - **Multi-location Support**: Create projects in different directories with flexible location management 35 | - **Multi-Host Deployment**: Deploy projects to any connected Docker host from the creation wizard 36 | - Edit and apply compose files directly from the web interface 37 | - **Create & Deploy**: One-click project creation with intelligent deployment and error handling 38 | - Visual tracking of compose stack status and stats across all hosts 39 | 40 | ### Docker Host Management 41 | - **Add Remote Hosts**: Connect to Docker hosts via TCP (e.g., `tcp://192.168.1.100:2375`) 42 | - **Host Configuration**: Easy setup with connection testing and validation 43 | - **System Overview**: View aggregated stats across all connected hosts 44 | - **Host Details**: Individual host information including Docker version and system resources 45 | - **Connection Management**: Test, add, remove, and monitor Docker host connections 46 | 47 | ### Backup & Restore System 48 | - **Complete Configuration Backup**: One-click backup of all containers, compose files, and settings 49 | - **Multi-Host Backup**: Backup configurations from all connected Docker hosts 50 | - **Unified Backup Archives**: Downloadable ZIP files containing everything needed for restoration 51 | - **Metadata Preservation**: Container tags, custom URLs, and stack assignments included 52 | - **Automated Restore**: Included restore scripts for easy deployment on new systems 53 | - **Backup History**: Track and manage previous backups locally 54 | 55 | ### Advanced Organization 56 | - **Stack Grouping**: Automatically groups containers by Docker Compose project across hosts 57 | - **Host Grouping**: Group and filter containers by Docker host 58 | - **Tag-based Organization**: Categorize containers with custom tags 59 | - **Multiple Sorting Options**: By name, CPU, memory, uptime, host, or tag 60 | - **Advanced Filtering**: By status, tags, stacks, hosts, or text search 61 | 62 | ### User Interface 63 | - Four theme options (Refined Dark, Night Owl, Nord, Light Breeze) 64 | - **Mobile-optimized design** with responsive layouts 65 | - **CodeMirror Editor**: Lightweight, fast syntax highlighting for YAML, shell scripts, and configuration files 66 | - **Dual View Modes**: Switch between grid and table views for optimal viewing 67 | - Batch operations for multiple containers across hosts 68 | - Desktop/mobile filter layout optimization 69 | - **Host Badges**: Visual indicators showing which host each container belongs to 70 | 71 | **🔄 Scheduled Repulls** *(Experimental)* see below 72 | **🤖 Auto-Safe Updates** *(Experimental)* see below 73 | 74 | ## Multi-Host Setup 75 | 76 | ### Enabling Docker Remote API 77 | 78 | To connect to remote Docker hosts, you need to enable the Docker Remote API on each target host. 79 | 80 | #### Method 1: Docker Daemon Configuration 81 | ```bash 82 | # Edit /etc/docker/daemon.json on the target host 83 | { 84 | "hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"] 85 | } 86 | 87 | # Restart Docker 88 | sudo systemctl restart docker 89 | ``` 90 | 91 | #### Method 2: Environment Variable Configuration 92 | Add to your Composr docker-compose.yml: 93 | ```yaml 94 | services: 95 | composr: 96 | environment: 97 | - DOCKER_HOSTS=local=unix:///var/run/docker.sock,prod=tcp://192.168.1.100:2375,staging=tcp://192.168.1.101:2375 98 | ``` 99 | 100 | #### Method 3: Web Interface (Recommended) 101 | 1. Go to the **Hosts** tab in Composr 102 | 2. Click **Add New Docker Host** 103 | 3. Enter the host details: 104 | - **Display Name**: e.g., "Production Server" 105 | - **Docker URL**: e.g., "tcp://192.168.1.100:2375" 106 | - **Description**: Optional description 107 | 4. Click **Test Connection** to verify 108 | 5. Click **Add Host** to save 109 | 110 | ### Security Considerations 111 | ⚠️ **Important**: Only enable the Docker Remote API on trusted networks. For production environments, consider using TLS certificates for secure connections. 112 | 113 | ## Project Creation Wizard 114 | 115 | The Project Creation feature supports multi-host deployment: 116 | 117 | ### How to Use 118 | 1. Go to **Config** tab → **Create** subtab 119 | 2. **Step 1**: Enter project name, choose location, and customize the compose template 120 | 3. **Step 2**: Review your project and choose deployment target: 121 | - **Local Docker**: Deploy to the local Composr host 122 | - **Remote Host**: Select any connected Docker host for deployment 123 | - **Create Only**: Just create the project files without deployment 124 | 125 | ### Multi-Host Features 126 | - **Host Selection**: Choose which Docker host to deploy your new project to 127 | - **Cross-Host Deployment**: Deploy from Composr to any connected Docker host 128 | - **Host-Specific Validation**: Ensures target host is available before deployment 129 | - **Deployment Feedback**: Detailed success/failure information for multi-host deployments 130 | 131 | ## Container Operations Across Hosts 132 | 133 | ### Supported Operations 134 | - **Start/Stop/Restart**: Control containers on any connected host 135 | - **Remove**: Delete containers from remote hosts 136 | - **Logs**: View real-time logs from containers on any host 137 | - **Terminal**: Execute commands in containers regardless of host location 138 | - **Inspect**: View detailed container information across hosts 139 | - **Batch Operations**: Perform actions on multiple containers across different hosts 140 | 141 | ### Host Identification 142 | - **Host Badges**: Every container shows which host it belongs to 143 | - **Host Filtering**: Filter view to show containers from specific hosts only 144 | - **Host Grouping**: Group containers by host for better organization 145 | - **Cross-Host Search**: Search for containers across all connected hosts 146 | 147 | ## Installation 148 | 149 | ### Platform-Specific Installation 150 | 151 | #### x86_64 / AMD64 Systems 152 | 153 | ```yaml 154 | services: 155 | composr: 156 | image: vansmak/composr:latest 157 | container_name: composr 158 | ports: 159 | - "5003:5003" 160 | volumes: 161 | - /var/run/docker.sock:/var/run/docker.sock 162 | - /path/to/docker/projects:/app/projects 163 | - /path/to/config/composr:/app/data 164 | - /path/to/config/caddy:/caddy_config # Optional 165 | environment: 166 | COMPOSE_DIR: /app/projects 167 | METADATA_DIR: /app/data 168 | CONTAINER_METADATA_FILE: /app/data/metadata.json 169 | CADDY_CONFIG_DIR: /caddy_config # Optional 170 | CADDY_CONFIG_FILE: Caddyfile # Optional 171 | EXTRA_COMPOSE_DIRS: /path1:/path2:/path3 # Optional 172 | # Multi-host configuration (optional) 173 | DOCKER_HOSTS: local=unix:///var/run/docker.sock,prod=tcp://192.168.1.100:2375 174 | restart: unless-stopped 175 | ``` 176 | 177 | #### Raspberry Pi / ARM Devices 178 | 179 | ```yaml 180 | services: 181 | composr: 182 | image: vansmak/composr:latest 183 | platform: linux/arm64 # Use this for 64-bit ARM (Pi 4) 184 | # OR use platform: linux/arm/v7 for 32-bit ARM (older Pi models) 185 | container_name: composr 186 | ports: 187 | - "5003:5003" 188 | volumes: 189 | - /var/run/docker.sock:/var/run/docker.sock 190 | - /path/to/docker/projects:/app/projects 191 | - /path/to/config/composr:/app/data 192 | environment: 193 | COMPOSE_DIR: /app/projects 194 | METADATA_DIR: /app/data 195 | # Multi-host configuration (optional) 196 | DOCKER_HOSTS: local=unix:///var/run/docker.sock,server2=tcp://192.168.1.101:2375 197 | restart: unless-stopped 198 | ``` 199 | 200 | ### Environment Variables 201 | 202 | **`COMPOSE_DIR`** 203 | Main directory for compose projects 204 | *Default:* `/app/projects` 205 | *Example:* `/home/user/docker` 206 | 207 | **`METADATA_DIR`** 208 | Directory for Composr metadata 209 | *Default:* `/app/data` 210 | *Example:* `/config/composr` 211 | 212 | **`DOCKER_HOSTS`** 213 | Comma-separated list of Docker hosts 214 | *Default:* `local=unix:///var/run/docker.sock` 215 | *Example:* `local=unix:///var/run/docker.sock,prod=tcp://192.168.1.100:2375` 216 | 217 | **`EXTRA_COMPOSE_DIRS`** 218 | Additional directories to scan for compose files 219 | *Default:* None 220 | *Example:* `/opt/stacks:/srv/docker` 221 | 222 | --- 223 | 224 | ## Backup & Restore 225 | 226 | ### Multi-Host Backup Support 227 | 228 | Composr's backup system now includes multi-host support: 229 | 230 | #### Creating Backups 231 | 1. Go to the **Backup** tab 232 | 2. Enter a backup name (or use the auto-generated timestamp) 233 | 3. Choose what to include (compose files, environment files) 234 | 4. Click **Create Backup** to download a ZIP archive containing configurations from all hosts 235 | 236 | #### What's Included in Multi-Host Backups 237 | - **All Host Configurations**: Container settings from all connected Docker hosts 238 | - **Host Metadata**: Information about which containers belong to which hosts 239 | - **Compose Files**: All docker-compose.yml files across all locations 240 | - **Environment Files**: All .env files from your projects 241 | - **Container Metadata**: Tags, custom URLs, and stack assignments across hosts 242 | - **Host Connection Info**: Details about connected Docker hosts (excluding sensitive data) 243 | 244 | ## Advanced Features 245 | 246 | ### Host Management 247 | - **Connection Monitoring**: Real-time status of all Docker host connections 248 | - **System Statistics**: CPU, memory, and container counts across all hosts 249 | - **Host Details**: Individual host information and Docker version 250 | - **Bulk Operations**: Perform actions across multiple hosts simultaneously 251 | 252 | ### Cross-Host Container Management 253 | - **Unified Interface**: Manage containers from multiple hosts in one view 254 | - **Host-Aware Operations**: All container operations include host context 255 | - **Intelligent Routing**: Commands automatically route to the correct Docker host 256 | - **Error Handling**: Detailed error reporting for cross-host operations 257 | 258 | ### Smart Filtering and Grouping 259 | - **Multi-Dimensional Filtering**: Filter by host, stack, status, and tags simultaneously 260 | - **Host-Based Grouping**: Group containers by Docker host for organized viewing 261 | - **Cross-Host Search**: Search for containers across all connected hosts 262 | - **Stack Management**: Manage Docker Compose stacks across multiple hosts 263 | 264 | ## Quick Start 265 | 266 | 1. **Install Composr** using the docker-compose examples above 267 | 2. **Access the interface** at `http://localhost:5003` 268 | 3. **Add Docker hosts** via the Hosts tab (optional, starts with local host) 269 | 4. **Create projects** using the Config → Create wizard 270 | 5. **Deploy across hosts** by selecting target hosts during project creation 271 | 6. **Manage containers** from the unified Containers view 272 | 273 | ## Security Notice 274 | 275 | ⚠️ **Warning**: This application has full control over Docker containers on all connected hosts. It should only be deployed in trusted environments and should not be exposed to the public internet without proper authentication. 276 | 277 | **Multi-Host Security**: When connecting to remote Docker hosts, ensure the Docker Remote API is only accessible on trusted networks. Consider using VPN or SSH tunnels for added security. 278 | 279 | **Backup Security**: Backup files contain your complete Docker configuration from all hosts including environment variables. Store backup files securely and avoid sharing them unless necessary. 280 | 281 | ### Container Update Management 🆕 ⚠️ **EXPERIMENTAL - UNTESTED** 282 | 283 | **⚠️ WARNING: This feature is experimental and has not been thoroughly tested. Use with caution in production environments. Always test in a development environment first.** 284 | 285 | Composr can automatically detect and manage updates for your Docker containers across all connected hosts. 286 | 287 | #### Update Detection Features 288 | - **Smart Version Detection**: Automatically detects newer versions of container images 289 | - **Semantic Versioning Support**: Understands version patterns like `1.0.0 → 1.0.1` 290 | - **Docker Hub Integration**: Checks Docker Hub API for latest image versions 291 | - **Latest Tag Monitoring**: Detects when "latest" tags have been updated with newer images 292 | - **Multi-Host Scanning**: Scans containers across all connected Docker hosts 293 | 294 | #### Update Types Supported 295 | 296 | **🤖 Auto-Safe Updates** *(Experimental)* 297 | - Automatically applies **patch version updates only** (e.g., `1.2.3 → 1.2.4`) 298 | - **Skips minor/major versions** that may contain breaking changes (e.g., `1.2.x → 1.3.x`) 299 | - Only updates containers with specified safe tags (e.g., `stable`, `prod`) 300 | - Creates automatic backups before updating 301 | - Configurable schedule (default: disabled for safety) 302 | 303 | **🔄 Scheduled Repulls** *(Experimental)* 304 | - Automatically repulls same version tags to get latest images 305 | - Useful for `latest`, `main`, or `stable` tags that get updated regularly 306 | - Configurable interval (e.g., daily, weekly) 307 | - Preserves exact same container configuration 308 | 309 | #### Exclusion System 310 | Multiple ways to exclude containers from automatic updates: 311 | 312 | - **Tag Patterns**: Exclude containers with tags like `dev`, `test`, `latest` 313 | - **Image Patterns**: Exclude images with names containing `test-`, `debug-` 314 | - **Container Patterns**: Exclude containers with names like `temp-`, `backup-` 315 | - **Specific Exclusions**: Manually exclude individual containers 316 | - **Include-Only Mode**: Only check containers with specific tags like `prod`, `stable` 317 | 318 | #### How to Use 319 | 320 | 1. **Enable Update Checking**: 321 | - Go to **Hosts** tab → Container Update Management 322 | - Click **⚙️ Update Settings** 323 | - Enable **"Automatically check for container updates"** 324 | 325 | 2. **Configure Safe Auto-Updates** *(Optional)*: 326 | ``` 327 | ☑️ Automatically apply safe updates (patch versions only) 328 | Tags to auto-update: stable, prod 329 | ``` 330 | 331 | 3. **Configure Scheduled Repulls** *(Optional)*: 332 | ``` 333 | ☑️ Automatically repull containers on schedule 334 | Repull interval: 24 hours 335 | Tags to repull: latest, main, stable 336 | ``` 337 | 338 | 4. **Set Exclusion Patterns**: 339 | ``` 340 | Exclude tag patterns: dev, test, nightly 341 | Include tag patterns: stable, prod (optional) 342 | ``` 343 | 344 | #### Manual Update Operations 345 | - **Check for Updates**: Click "📦 Updates" button to scan all containers 346 | - **Batch Updates**: Update multiple containers simultaneously with checkboxes 347 | - **Individual Updates**: Update single containers with version selection 348 | - **Update Preview**: See what would be updated before applying changes 349 | 350 | #### Update Detection Examples 351 | 352 | | Container Image | Detection Method | Update Available | 353 | |---|---|---| 354 | | `nginx:1.20` | Version comparison | `nginx:1.21` ✅ | 355 | | `postgres:latest` | Timestamp comparison | Newer `latest` image ✅ | 356 | | `myapp:v2.1.0` | Semantic versioning | `v2.1.1`, `v2.2.0` ✅ | 357 | | `redis:alpine` | Tag-based | Updated `alpine` tag ✅ | 358 | | `custom:dev` | Excluded by pattern | Skipped ⏭️ | 359 | 360 | #### Safety Features 361 | - **Backup Creation**: Automatic backups before any updates 362 | - **Rollback Support**: Restore previous version if update fails 363 | - **Compose Integration**: Updates compose files and redeploys safely 364 | - **Error Handling**: Detailed error reporting and recovery options 365 | - **Dry Run Mode**: Preview what would be updated without applying changes 366 | 367 | #### Multi-Host Support 368 | - Works across all connected Docker hosts 369 | - Host-aware update routing 370 | - Unified update management interface 371 | - Per-host update status and statistics 372 | 373 | #### API Endpoints *(For Advanced Users)* 374 | ```bash 375 | # Check for updates 376 | POST /api/container-updates/check 377 | 378 | # Update specific container 379 | POST /api/container-updates/update 380 | 381 | # Batch update multiple containers 382 | POST /api/container-updates/batch-update 383 | 384 | # Manage exclusions 385 | GET/POST /api/container-updates/exclusions 386 | 387 | # Trigger auto-maintenance 388 | POST /api/container-updates/auto-maintenance 389 | ``` 390 | 391 | #### ⚠️ Important Safety Notes 392 | 393 | **🔴 CRITICAL: This feature is experimental and untested in production environments.** 394 | 395 | - **Test thoroughly** in development before using in production 396 | - **Always backup** your containers and compose files before enabling auto-updates 397 | - **Start with exclusions** - exclude critical production containers initially 398 | - **Monitor logs** for update operations and errors 399 | - **Verify updates** work correctly before enabling automation 400 | - **Have rollback plan** ready in case updates cause issues 401 | 402 | **Recommended First Steps:** 403 | 1. Enable update **checking only** (disable auto-updates) 404 | 2. Test manual updates on non-critical containers 405 | 3. Verify backup and rollback functionality 406 | 4. Gradually enable auto-updates for safe containers only 407 | 5. Monitor for several weeks before fully trusting the system 408 | 409 | **Not Recommended For:** 410 | - Database containers without proper backup strategies 411 | - Containers with custom configurations that may break 412 | - Production systems without thorough testing 413 | - Containers that require manual intervention during updates 414 | - Any container where downtime is not acceptable 415 | 416 | #### Configuration Location 417 | Update settings are stored in: `${METADATA_DIR}/container_update_settings.json` 418 | 419 | ## Screenshots 420 | 421 | ![Composr Main Screen](https://github.com/user-attachments/assets/49876da2-7131-4430-817a-d16f4ef6f673) 422 | ![Composr Config Screen](https://github.com/user-attachments/assets/dc4b4347-2032-4ede-b302-229d828c0b1c) 423 | ![Composr Mobile](https://github.com/user-attachments/assets/e0225c62-83cb-4a38-928f-2f56b033e393) 424 | -------------------------------------------------------------------------------- /static/js/table-view.js: -------------------------------------------------------------------------------- 1 | // Table-specific functions for container management 2 | 3 | function updateTableHeaders() { 4 | const headersRow = document.getElementById('table-headers-row'); 5 | if (headersRow) { 6 | headersRow.innerHTML = ` 7 | 8 | Name 9 | Stack 10 | Status 11 | Uptime 12 | Ports 13 | Host 14 | Actions 15 | `; 16 | 17 | // Re-add select all functionality 18 | const selectAllCheckbox = document.getElementById('select-all'); 19 | if (selectAllCheckbox) { 20 | selectAllCheckbox.addEventListener('change', (e) => { 21 | const checkboxes = document.querySelectorAll('.batch-checkbox'); 22 | checkboxes.forEach(checkbox => { 23 | checkbox.checked = e.target.checked; 24 | const row = checkbox.closest('tr'); 25 | if (row) { 26 | row.classList.toggle('selected', e.target.checked); 27 | } 28 | }); 29 | }); 30 | } 31 | } 32 | } 33 | 34 | // Also update ensureTableStructure to set table layout 35 | function ensureTableStructure() { 36 | const tableView = document.getElementById('table-view'); 37 | let containerTable = document.getElementById('container-table'); 38 | 39 | if (!containerTable) { 40 | console.log('Creating container table structure'); 41 | containerTable = document.createElement('table'); 42 | containerTable.id = 'container-table'; 43 | containerTable.style.tableLayout = 'auto'; 44 | //containerTable.style.width = '100%'; 45 | 46 | const thead = document.createElement('thead'); 47 | const headerRow = document.createElement('tr'); 48 | headerRow.id = 'table-headers-row'; 49 | 50 | const tbody = document.createElement('tbody'); 51 | tbody.id = 'table-body'; 52 | 53 | thead.appendChild(headerRow); 54 | containerTable.appendChild(thead); 55 | containerTable.appendChild(tbody); 56 | tableView.appendChild(containerTable); 57 | } else { 58 | // Ensure existing table has proper layout 59 | containerTable.style.tableLayout = 'auto'; 60 | //containerTable.style.width = '100%'; 61 | } 62 | 63 | // Ensure table body exists 64 | let tableBody = document.getElementById('table-body'); 65 | if (!tableBody) { 66 | tableBody = document.createElement('tbody'); 67 | tableBody.id = 'table-body'; 68 | containerTable.appendChild(tableBody); 69 | } 70 | 71 | // Force table to be visible and properly styled 72 | containerTable.style.display = 'table'; 73 | tableView.style.display = 'block'; 74 | 75 | console.log('Table structure ensured with fixed layout'); 76 | } 77 | 78 | // Main table rendering function 79 | function renderContainersAsTable(containers) { 80 | const tableView = document.getElementById('table-view'); 81 | const tableBody = document.getElementById('table-body'); 82 | const noContainers = document.getElementById('no-containers'); 83 | 84 | if (!tableBody) { 85 | console.error('Table body not found! Creating...'); 86 | ensureTableStructure(); 87 | return; 88 | } 89 | 90 | // Clear existing rows 91 | tableBody.innerHTML = ''; 92 | 93 | const group = document.getElementById('group-filter').value || document.getElementById('group-filter-mobile').value || 'none'; 94 | 95 | // Set batch mode state 96 | const isBatchMode = document.getElementById('containers-list').classList.contains('batch-mode'); 97 | tableView.classList.toggle('batch-mode', isBatchMode); 98 | 99 | if (!Array.isArray(containers) || !containers.length) { 100 | noContainers.style.display = 'block'; 101 | const emptyRow = document.createElement('tr'); 102 | emptyRow.innerHTML = 'No containers found.'; 103 | tableBody.appendChild(emptyRow); 104 | return; 105 | } 106 | 107 | noContainers.style.display = 'none'; 108 | 109 | // Update table headers for current grouping 110 | updateTableHeaders(); 111 | 112 | // Render based on grouping 113 | if (group === 'stack') { 114 | renderContainersByStackAsTable(containers); 115 | } else if (group === 'host') { 116 | renderContainersByHostAsTable(containers); 117 | } else { 118 | // Render without grouping 119 | containers.forEach(container => { 120 | renderSingleContainerAsTableRow(container, tableBody); 121 | }); 122 | } 123 | 124 | // Force table repaint 125 | const containerTable = document.getElementById('container-table'); 126 | if (containerTable) { 127 | containerTable.style.display = 'none'; 128 | containerTable.offsetHeight; // Force reflow 129 | containerTable.style.display = 'table'; 130 | } 131 | 132 | console.log(`Rendered ${containers.length} containers in table view`); 133 | } 134 | 135 | // Render containers grouped by stack in table format 136 | function renderContainersByStackAsTable(containers) { 137 | const tableBody = document.getElementById('table-body'); 138 | let allTags = new Set(); 139 | let allStacks = new Set(); 140 | let stackContainers = {}; 141 | 142 | containers.forEach(container => { 143 | const stackName = window.extractStackName(container); 144 | allStacks.add(stackName); 145 | if (!stackContainers[stackName]) { 146 | stackContainers[stackName] = []; 147 | } 148 | stackContainers[stackName].push(container); 149 | if (container.tags && Array.isArray(container.tags)) { 150 | container.tags.forEach(tag => allTags.add(tag)); 151 | } 152 | }); 153 | 154 | // Calculate stats for each stack 155 | const stackStats = {}; 156 | Object.keys(stackContainers).forEach(stackName => { 157 | const stackGroup = stackContainers[stackName]; 158 | stackStats[stackName] = { 159 | total: stackGroup.length, 160 | running: stackGroup.filter(c => c.status === 'running').length, 161 | cpu: stackGroup.reduce((sum, c) => sum + (parseFloat(c.cpu_percent) || 0), 0).toFixed(1), 162 | memory: Math.round(stackGroup.reduce((sum, c) => sum + (parseFloat(c.memory_usage) || 0), 0)) 163 | }; 164 | }); 165 | 166 | // Render each stack with its containers 167 | Object.keys(stackContainers).sort().forEach(stackName => { 168 | const stats = stackStats[stackName]; 169 | const composeFile = window.findComposeFileForStack(stackContainers[stackName]); 170 | 171 | // Create stack header row 172 | const headerRow = document.createElement('tr'); 173 | headerRow.className = 'stack-header-row'; 174 | headerRow.innerHTML = ` 175 | 176 |
177 |

${stackName}

178 |
179 |
180 | ${stats.running}/${stats.total} running 181 | CPU: ${stats.cpu}% 182 | Mem: ${stats.memory} MB 183 |
184 | 187 |
188 |
189 | 190 | `; 191 | tableBody.appendChild(headerRow); 192 | 193 | // Render containers for this stack 194 | stackContainers[stackName].forEach(container => { 195 | renderSingleContainerAsTableRow(container, tableBody); 196 | }); 197 | }); 198 | } 199 | 200 | // Render containers grouped by host in table format 201 | function renderContainersByHostAsTable(containers) { 202 | const tableBody = document.getElementById('table-body'); 203 | let hostGroups = {}; 204 | 205 | // Group containers by host 206 | containers.forEach(container => { 207 | const host = container.host || 'local'; 208 | if (!hostGroups[host]) { 209 | hostGroups[host] = []; 210 | } 211 | hostGroups[host].push(container); 212 | }); 213 | 214 | // Render each host group 215 | Object.keys(hostGroups).sort().forEach(host => { 216 | const hostContainers = hostGroups[host]; 217 | const hostDisplay = hostContainers[0]?.host_display || host; 218 | 219 | const stats = { 220 | total: hostContainers.length, 221 | running: hostContainers.filter(c => c.status === 'running').length, 222 | cpu: hostContainers.reduce((sum, c) => sum + (parseFloat(c.cpu_percent) || 0), 0).toFixed(1), 223 | memory: Math.round(hostContainers.reduce((sum, c) => sum + (parseFloat(c.memory_usage) || 0), 0)) 224 | }; 225 | 226 | // Create host header row 227 | const headerRow = document.createElement('tr'); 228 | headerRow.className = 'stack-header-row'; 229 | headerRow.innerHTML = ` 230 | 231 |
232 |

🖥️ ${hostDisplay}

233 |
234 |
235 | ${stats.running}/${stats.total} running 236 | CPU: ${stats.cpu}% 237 | Mem: ${stats.memory} MB 238 |
239 | 242 |
243 |
244 | 245 | `; 246 | tableBody.appendChild(headerRow); 247 | 248 | // Sort and render containers for this host 249 | hostContainers.sort((a, b) => a.name.localeCompare(b.name)); 250 | hostContainers.forEach(container => { 251 | renderSingleContainerAsTableRow(container, tableBody); 252 | }); 253 | }); 254 | } 255 | 256 | // Render a single container as a table row 257 | function renderSingleContainerAsTableRow(container, tableBody) { 258 | const isBatchMode = document.getElementById('table-view').classList.contains('batch-mode'); 259 | const row = document.createElement('tr'); 260 | row.dataset.id = container.id; 261 | row.dataset.host = container.host || 'local'; 262 | const uptimeDisplay = container.uptime && container.uptime.display ? container.uptime.display : 'N/A'; 263 | 264 | // CRITICAL FIX: Ensure host is properly passed 265 | const containerHost = container.host || 'local'; 266 | console.log(`Rendering table row for ${container.name} with host: ${containerHost}`); 267 | 268 | // Create ports HTML for table 269 | let portsHtml = ''; 270 | if (container.ports && Object.keys(container.ports).length > 0) { 271 | const portsList = Object.entries(container.ports) 272 | .map(([hostPort, containerPort]) => `${hostPort}:${containerPort}`) 273 | .slice(0, 2) 274 | .join(', '); 275 | 276 | const remainingPorts = Object.keys(container.ports).length - 2; 277 | portsHtml = remainingPorts > 0 ? `${portsList} +${remainingPorts}` : portsList; 278 | } else { 279 | portsHtml = 'None'; 280 | } 281 | 282 | // ADD THIS: Get health info for table 283 | const health = getContainerHealth(container); 284 | 285 | const hostDisplay = container.host_display || container.host || 'local'; 286 | 287 | row.innerHTML = ` 288 | 289 | 290 | 291 | 292 | ${container.name} 293 | 294 | ${window.extractStackName(container)} 295 | 296 | ${container.status} 297 | 298 | 299 | 300 | ${uptimeDisplay} 301 | 302 | ${portsHtml} 303 | ${hostDisplay} 304 | 305 |
306 | 307 | 308 | 309 | 310 |
311 | 312 | `; 313 | 314 | if (isBatchMode) { 315 | const checkbox = row.querySelector('.batch-checkbox'); 316 | checkbox.addEventListener('change', (e) => { 317 | row.classList.toggle('selected', e.target.checked); 318 | }); 319 | } 320 | 321 | tableBody.appendChild(row); 322 | } 323 | // Render containers grouped by tag in table format (if needed) 324 | function renderContainersByTagAsTable(containers) { 325 | const tableBody = document.getElementById('table-body'); 326 | let groupedByTag = {}; 327 | 328 | containers.forEach(container => { 329 | const primaryTag = container.tags && container.tags.length ? container.tags[0] : 'Untagged'; 330 | if (!groupedByTag[primaryTag]) { 331 | groupedByTag[primaryTag] = []; 332 | } 333 | groupedByTag[primaryTag].push(container); 334 | }); 335 | 336 | Object.keys(groupedByTag).sort().forEach(tag => { 337 | const headerRow = document.createElement('tr'); 338 | headerRow.className = 'stack-header-row'; 339 | headerRow.innerHTML = ` 340 | 341 |

${tag}

342 | 343 | `; 344 | tableBody.appendChild(headerRow); 345 | 346 | groupedByTag[tag].forEach(container => { 347 | renderSingleContainerAsTableRow(container, tableBody); 348 | }); 349 | }); 350 | } 351 | 352 | // Enhanced batch selection with host awareness 353 | function toggleAllContainers() { 354 | const tableView = document.getElementById('table-view'); 355 | const isTableView = tableView && tableView.classList.contains('active'); 356 | 357 | if (isTableView) { 358 | const checkboxes = document.querySelectorAll('.batch-checkbox'); 359 | const selectAllCheckbox = document.getElementById('select-all'); 360 | const allSelected = Array.from(checkboxes).every(cb => cb.checked); 361 | 362 | checkboxes.forEach(checkbox => { 363 | checkbox.checked = !allSelected; 364 | const row = checkbox.closest('tr'); 365 | if (row) { 366 | row.classList.toggle('selected', checkbox.checked); 367 | } 368 | }); 369 | 370 | if (selectAllCheckbox) { 371 | selectAllCheckbox.checked = !allSelected; 372 | } 373 | } else { 374 | // Grid view 375 | const checkboxes = document.querySelectorAll('.container-select'); 376 | const allSelected = Array.from(checkboxes).every(cb => cb.checked); 377 | 378 | checkboxes.forEach(checkbox => { 379 | checkbox.checked = !allSelected; 380 | checkbox.dispatchEvent(new Event('change')); 381 | }); 382 | } 383 | } 384 | 385 | // Simplified sorting for table view 386 | function sortTable(key) { 387 | let direction = 'asc'; 388 | 389 | // For metrics (cpu, memory, uptime), default to descending (highest first) 390 | if (key === 'cpu' || key === 'memory' || key === 'uptime') { 391 | direction = 'desc'; 392 | } 393 | // For name and status, use ascending (A-Z) 394 | else { 395 | direction = 'asc'; 396 | } 397 | 398 | // Update visual indicators 399 | document.querySelectorAll('#table-view th[onclick]').forEach(th => { 400 | th.classList.remove('sorted-asc', 'sorted-desc'); 401 | }); 402 | 403 | const currentHeader = document.querySelector(`#table-view th[onclick="sortTable('${key}')"]`); 404 | if (currentHeader) { 405 | currentHeader.classList.add(`sorted-${direction}`); 406 | } 407 | 408 | // Call refreshContainers with sort parameters 409 | refreshContainers(key, direction); 410 | } 411 | function ensureImagesTableStructure() { 412 | const imagesTableView = document.getElementById('images-table-view'); 413 | if (!imagesTableView) { 414 | console.error('Images table view container not found'); 415 | return; 416 | } 417 | 418 | let imagesTable = document.getElementById('images-table'); 419 | 420 | if (!imagesTable) { 421 | console.log('Creating images table structure'); 422 | imagesTable = document.createElement('table'); 423 | imagesTable.id = 'images-table'; 424 | imagesTable.className = 'images-table'; 425 | 426 | const thead = document.createElement('thead'); 427 | const headerRow = document.createElement('tr'); 428 | 429 | // FIXED: Include Host header 430 | headerRow.innerHTML = ` 431 | Name 432 | Tags 433 | Size 434 | Created 435 | Used By 436 | Host 437 | Actions 438 | `; 439 | 440 | const tbody = document.createElement('tbody'); 441 | tbody.id = 'images-table-body'; 442 | 443 | thead.appendChild(headerRow); 444 | imagesTable.appendChild(thead); 445 | imagesTable.appendChild(tbody); 446 | imagesTableView.appendChild(imagesTable); 447 | 448 | console.log('Images table structure created with Host header'); 449 | } 450 | 451 | // Ensure table is properly styled and visible 452 | imagesTable.style.width = '100%'; 453 | imagesTable.style.borderCollapse = 'collapse'; 454 | imagesTable.style.display = 'table'; 455 | 456 | return imagesTable; 457 | } 458 | 459 | // Add this new function to handle image sorting: 460 | function sortImages(sortBy) { 461 | console.log(`Sorting images by: ${sortBy}`); 462 | 463 | // Get current images data 464 | const tableBody = document.getElementById('images-table-body'); 465 | if (!tableBody) return; 466 | 467 | const rows = Array.from(tableBody.querySelectorAll('tr')); 468 | 469 | // Extract data from rows for sorting 470 | const imageData = rows.map(row => { 471 | const cells = row.querySelectorAll('td'); 472 | return { 473 | element: row, 474 | name: cells[0]?.textContent || '', 475 | size: parseFloat(cells[2]?.textContent?.replace(' MB', '') || '0'), 476 | created: cells[3]?.textContent || '', 477 | host: cells[5]?.textContent?.trim() || 'local' 478 | }; 479 | }); 480 | 481 | // Sort the data 482 | imageData.sort((a, b) => { 483 | let valueA, valueB; 484 | 485 | switch(sortBy) { 486 | case 'name': 487 | valueA = a.name.toLowerCase(); 488 | valueB = b.name.toLowerCase(); 489 | return valueA.localeCompare(valueB); 490 | 491 | case 'size': 492 | return b.size - a.size; // Descending for size 493 | 494 | case 'created': 495 | valueA = a.created.toLowerCase(); 496 | valueB = b.created.toLowerCase(); 497 | return valueB.localeCompare(valueA); // Descending for created date 498 | 499 | case 'host': 500 | valueA = a.host.toLowerCase(); 501 | valueB = b.host.toLowerCase(); 502 | // Sort by host first, then by name within each host 503 | if (valueA !== valueB) { 504 | return valueA.localeCompare(valueB); 505 | } 506 | return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); 507 | 508 | default: 509 | return 0; 510 | } 511 | }); 512 | 513 | // Clear table body and re-append sorted rows 514 | tableBody.innerHTML = ''; 515 | imageData.forEach(item => { 516 | tableBody.appendChild(item.element); 517 | }); 518 | 519 | // Update visual indicators 520 | document.querySelectorAll('#images-table th[onclick]').forEach(th => { 521 | th.classList.remove('sorted-asc', 'sorted-desc'); 522 | }); 523 | 524 | const currentHeader = document.querySelector(`#images-table th[onclick="sortImages('${sortBy}')"]`); 525 | if (currentHeader) { 526 | // For size and created, show descending. For name and host, show ascending 527 | const isDescending = sortBy === 'size' || sortBy === 'created'; 528 | currentHeader.classList.add(isDescending ? 'sorted-desc' : 'sorted-asc'); 529 | } 530 | } 531 | 532 | // Initialize table view event listeners when DOM is ready 533 | document.addEventListener('DOMContentLoaded', () => { 534 | // Select all functionality 535 | const selectAllCheckbox = document.getElementById('select-all'); 536 | if (selectAllCheckbox) { 537 | selectAllCheckbox.addEventListener('change', (e) => { 538 | const checkboxes = document.querySelectorAll('.batch-checkbox'); 539 | checkboxes.forEach(checkbox => { 540 | checkbox.checked = e.target.checked; 541 | const row = checkbox.closest('tr'); 542 | if (row) { 543 | row.classList.toggle('selected', e.target.checked); 544 | } 545 | }); 546 | }); 547 | } 548 | 549 | // Event listener for group filter changes to update table headers 550 | const groupFilter = document.getElementById('group-filter'); 551 | const groupFilterMobile = document.getElementById('group-filter-mobile'); 552 | 553 | if (groupFilter) { 554 | groupFilter.addEventListener('change', () => { 555 | setTimeout(updateTableHeaders, 100); // Small delay to ensure DOM updates 556 | }); 557 | } 558 | 559 | if (groupFilterMobile) { 560 | groupFilterMobile.addEventListener('change', () => { 561 | setTimeout(updateTableHeaders, 100); 562 | }); 563 | } 564 | 565 | // Also listen for view changes 566 | const toggleViewBtn = document.getElementById('toggle-view'); 567 | if (toggleViewBtn) { 568 | toggleViewBtn.addEventListener('click', () => { 569 | setTimeout(updateTableHeaders, 200); 570 | }); 571 | } 572 | }); 573 | 574 | // Enhanced image table rendering for multi-host 575 | function renderImageTableRow(image, tableBody) { 576 | const row = document.createElement('tr'); 577 | const isUsed = image.used_by && image.used_by.length > 0; 578 | const hostDisplay = image.host_display || image.host || 'local'; 579 | 580 | row.innerHTML = ` 581 | ${image.name} 582 | ${image.tags.join(', ')} 583 | ${image.size} MB 584 | ${image.created} 585 | ${isUsed ? image.used_by.join(', ') : 'None'} 586 | ${hostDisplay} 587 | 588 | 589 | 590 | `; 591 | tableBody.appendChild(row); 592 | } 593 | 594 | // Export functions to window for global access 595 | window.ensureTableStructure = ensureTableStructure; 596 | window.updateTableHeaders = updateTableHeaders; 597 | window.renderContainersAsTable = renderContainersAsTable; 598 | window.renderImageTableRow = renderImageTableRow; 599 | window.renderContainersByTagAsTable = renderContainersByTagAsTable; 600 | window.sortTable = sortTable; -------------------------------------------------------------------------------- /static/js/remote.js: -------------------------------------------------------------------------------- 1 | // static/js/remote.js - Fixed Composr Host Management 2 | 3 | // Load and display Docker hosts management 4 | function loadHostsManagement() { 5 | fetch('/api/hosts') 6 | .then(response => response.json()) 7 | .then(data => { 8 | if (data.status === 'success') { 9 | updateHostsDisplay(data.hosts, data.current_host); 10 | loadMultiHostSystemOverview(); 11 | } else { 12 | showMessage('error', 'Failed to load hosts'); 13 | } 14 | }) 15 | .catch(error => { 16 | console.error('Failed to load hosts:', error); 17 | showMessage('error', 'Failed to load hosts'); 18 | }); 19 | } 20 | 21 | // Add a new Docker host 22 | function addDockerHost() { 23 | const name = document.getElementById('new-host-name').value.trim(); 24 | const url = document.getElementById('new-host-url').value.trim(); 25 | const description = document.getElementById('new-host-description')?.value.trim() || ''; 26 | 27 | if (!name || !url) { 28 | showMessage('error', 'Name and URL are required'); 29 | return; 30 | } 31 | 32 | // Validate URL format for Docker 33 | if (!url.startsWith('tcp://')) { 34 | showMessage('error', 'URL must start with tcp:// (e.g., tcp://192.168.1.100:2375)'); 35 | return; 36 | } 37 | 38 | setLoading(true, `Adding host ${name}...`); 39 | 40 | fetch('/api/hosts/add', { 41 | method: 'POST', 42 | headers: { 'Content-Type': 'application/json' }, 43 | body: JSON.stringify({ name, url, description }) 44 | }) 45 | .then(response => response.json()) 46 | .then(result => { 47 | setLoading(false); 48 | if (result.status === 'success') { 49 | showMessage('success', result.message); 50 | // Clear form 51 | document.getElementById('new-host-name').value = ''; 52 | document.getElementById('new-host-url').value = ''; 53 | if (document.getElementById('new-host-description')) { 54 | document.getElementById('new-host-description').value = ''; 55 | } 56 | // Reload host management 57 | loadHostsManagement(); 58 | } else { 59 | showMessage('error', result.message); 60 | } 61 | }) 62 | .catch(error => { 63 | setLoading(false); 64 | showMessage('error', 'Failed to add Docker host'); 65 | console.error('Add host error:', error); 66 | }); 67 | } 68 | 69 | // Remove a Docker host 70 | function removeHost(hostName) { 71 | if (!confirm(`Remove Docker host "${hostName}"? This will disconnect from the host but won't affect the actual Docker host.`)) { 72 | return; 73 | } 74 | 75 | setLoading(true, `Removing host ${hostName}...`); 76 | 77 | fetch('/api/hosts/remove', { 78 | method: 'POST', 79 | headers: { 'Content-Type': 'application/json' }, 80 | body: JSON.stringify({ name: hostName }) 81 | }) 82 | .then(response => response.json()) 83 | .then(result => { 84 | setLoading(false); 85 | if (result.status === 'success') { 86 | showMessage('success', result.message); 87 | loadHostsManagement(); 88 | // Refresh containers as the host list changed 89 | if (typeof refreshContainers === 'function') { 90 | refreshContainers(); 91 | } 92 | } else { 93 | showMessage('error', result.message); 94 | } 95 | }) 96 | .catch(error => { 97 | setLoading(false); 98 | showMessage('error', 'Failed to remove host'); 99 | console.error('Remove host error:', error); 100 | }); 101 | } 102 | 103 | // Test Docker host connection 104 | function testHost(hostName, url) { 105 | if (!url) { 106 | showMessage('error', 'Please enter a Docker URL to test'); 107 | return; 108 | } 109 | 110 | setLoading(true, `Testing connection to ${hostName || 'host'}...`); 111 | 112 | fetch('/api/hosts/test', { 113 | method: 'POST', 114 | headers: { 'Content-Type': 'application/json' }, 115 | body: JSON.stringify({ url }) 116 | }) 117 | .then(response => response.json()) 118 | .then(result => { 119 | setLoading(false); 120 | if (result.status === 'success') { 121 | showMessage('success', `Connection to ${hostName || 'host'} successful`); 122 | } else { 123 | showMessage('error', `Connection failed: ${result.message}`); 124 | } 125 | }) 126 | .catch(error => { 127 | setLoading(false); 128 | showMessage('error', `Failed to test connection to ${hostName || 'host'}`); 129 | console.error('Test host error:', error); 130 | }); 131 | } 132 | 133 | // Switch to a different Docker host (for current context) 134 | function switchToHost(hostName) { 135 | setLoading(true, `Switching to ${hostName}...`); 136 | 137 | fetch('/api/hosts/switch', { 138 | method: 'POST', 139 | headers: { 'Content-Type': 'application/json' }, 140 | body: JSON.stringify({ host: hostName }) 141 | }) 142 | .then(response => response.json()) 143 | .then(result => { 144 | setLoading(false); 145 | if (result.status === 'success') { 146 | showMessage('success', result.message); 147 | // Reload everything for the new host context 148 | loadHostsManagement(); 149 | if (typeof refreshContainers === 'function') { 150 | refreshContainers(); 151 | } 152 | if (typeof loadSystemStatsMultiHost === 'function') { 153 | loadSystemStatsMultiHost(); 154 | } 155 | } else { 156 | showMessage('error', result.message); 157 | } 158 | }) 159 | .catch(error => { 160 | setLoading(false); 161 | showMessage('error', `Failed to switch to ${hostName}`); 162 | console.error('Switch host error:', error); 163 | }); 164 | } 165 | 166 | // Update the hosts display 167 | function updateHostsDisplay(hosts, currentHost) { 168 | const container = document.getElementById('hosts-list-content'); 169 | if (!container) return; 170 | 171 | container.innerHTML = ''; 172 | 173 | // Group hosts by connection status 174 | const connectedHosts = []; 175 | const disconnectedHosts = []; 176 | 177 | Object.entries(hosts).forEach(([hostName, hostInfo]) => { 178 | if (hostName === 'local') return; // Skip local in the list 179 | 180 | if (hostInfo.connected) { 181 | connectedHosts.push([hostName, hostInfo]); 182 | } else { 183 | disconnectedHosts.push([hostName, hostInfo]); 184 | } 185 | }); 186 | 187 | // Render connected hosts 188 | if (connectedHosts.length > 0) { 189 | const connectedSection = document.createElement('div'); 190 | connectedSection.className = 'host-section'; 191 | connectedSection.innerHTML = '
🟢 Connected
'; 192 | container.appendChild(connectedSection); 193 | 194 | connectedHosts.forEach(([hostName, hostInfo]) => { 195 | const hostDiv = createHostListItem(hostName, hostInfo, currentHost); 196 | container.appendChild(hostDiv); 197 | }); 198 | } 199 | 200 | // Render disconnected hosts 201 | if (disconnectedHosts.length > 0) { 202 | const disconnectedSection = document.createElement('div'); 203 | disconnectedSection.className = 'host-section'; 204 | disconnectedSection.innerHTML = '
🔴 Disconnected
'; 205 | container.appendChild(disconnectedSection); 206 | 207 | disconnectedHosts.forEach(([hostName, hostInfo]) => { 208 | const hostDiv = createHostListItem(hostName, hostInfo, currentHost); 209 | container.appendChild(hostDiv); 210 | }); 211 | } 212 | 213 | // Show message if no external hosts 214 | if (connectedHosts.length === 0 && disconnectedHosts.length === 0) { 215 | container.innerHTML = '

No external Docker hosts configured. Add a host using the form below.

'; 216 | } 217 | } 218 | 219 | // Create a host list item 220 | function createHostListItem(hostName, hostInfo, currentHost) { 221 | const hostDiv = document.createElement('div'); 222 | hostDiv.className = `host-item ${hostInfo.connected ? 'connected' : 'disconnected'}`; 223 | 224 | const statusIcon = hostInfo.connected ? '🟢' : '🔴'; 225 | 226 | hostDiv.innerHTML = ` 227 |
228 |
229 | ${statusIcon} ${hostInfo.name || hostName} 230 | DEPLOY TARGET 231 |
232 |
233 | ${hostInfo.url} 234 | ${hostInfo.connected ? ` 235 | tcp 236 | Last check: ${formatLastCheck(hostInfo.last_check)} 237 | ` : ` 238 | Connection failed 239 | `} 240 |
241 |
242 |
243 | ${hostInfo.connected ? ` 244 | Available for deployment 245 | 246 | ` : ` 247 | 248 | `} 249 | 250 |
251 | `; 252 | 253 | return hostDiv; 254 | } 255 | 256 | // Format last check timestamp 257 | function formatLastCheck(timestamp) { 258 | if (!timestamp) return 'Never'; 259 | const now = Date.now() / 1000; 260 | const diff = Math.floor(now - timestamp); 261 | 262 | if (diff < 60) return `${diff}s ago`; 263 | if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 264 | if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 265 | return `${Math.floor(diff / 86400)}d ago`; 266 | } 267 | 268 | // Load multi-host system overview 269 | function loadMultiHostSystemOverview() { 270 | fetch('/api/system/overview') 271 | .then(response => response.json()) 272 | .then(data => { 273 | if (data.status === 'success') { 274 | updateMultiHostStats(data.hosts, data.totals); 275 | } else { 276 | console.error('System overview error:', data.message); 277 | } 278 | }) 279 | .catch(error => { 280 | console.error('Failed to load system overview:', error); 281 | }); 282 | } 283 | 284 | // Update multi-host statistics display 285 | function updateMultiHostStats(hosts, totals) { 286 | const container = document.getElementById('multi-host-stats'); 287 | if (!container) return; 288 | 289 | container.innerHTML = ` 290 |
291 | ${totals.connected_hosts || 0} 292 | Connected Hosts 293 |
294 |
295 | ${totals.total_containers || 0} 296 | Total Containers 297 |
298 |
299 | ${totals.total_running || 0} 300 | Running Containers 301 |
302 |
303 | ${totals.total_cpu_cores || 0} 304 | Total CPU Cores 305 |
306 |
307 | ${totals.total_memory_gb || 0} GB 308 | Total Memory 309 |
310 |
311 | ${totals.total_images || 0} 312 | Total Images 313 |
314 | `; 315 | } 316 | // Load available Docker hosts for deployment 317 | function loadAvailableHosts() { 318 | fetch('/api/hosts') 319 | .then(response => response.json()) 320 | .then(data => { 321 | const select = document.getElementById('deploy-host-select'); 322 | if (!select) return; 323 | 324 | // Clear existing options except local 325 | select.innerHTML = ''; 326 | 327 | if (data.status === 'success' && data.hosts) { 328 | Object.entries(data.hosts).forEach(([hostName, hostInfo]) => { 329 | if (hostName !== 'local' && hostInfo.connected) { 330 | const option = document.createElement('option'); 331 | option.value = hostName; 332 | option.textContent = `${hostInfo.name || hostName} (${hostInfo.url})`; 333 | 334 | // Mark current host 335 | if (hostName === data.current_host) { 336 | option.textContent += ' [Current]'; 337 | } 338 | 339 | select.appendChild(option); 340 | } 341 | }); 342 | 343 | // Show disconnected hosts as disabled options 344 | Object.entries(data.hosts).forEach(([hostName, hostInfo]) => { 345 | if (hostName !== 'local' && !hostInfo.connected) { 346 | const option = document.createElement('option'); 347 | option.value = hostName; 348 | option.textContent = `${hostInfo.name || hostName} (Disconnected)`; 349 | option.disabled = true; 350 | option.style.color = 'var(--text-disabled)'; 351 | select.appendChild(option); 352 | } 353 | }); 354 | } 355 | }) 356 | .catch(error => { 357 | console.error('Failed to load hosts:', error); 358 | showMessage('error', 'Failed to load available hosts'); 359 | }); 360 | } 361 | // Legacy compatibility functions - keeping these for compatibility with other parts of the app 362 | function loadDockerHosts() { 363 | fetch('/api/docker/hosts') 364 | .then(response => response.json()) 365 | .then(data => { 366 | const instanceSelector = document.getElementById('composr-instance-selector'); 367 | if (instanceSelector) { 368 | instanceSelector.innerHTML = ''; 369 | 370 | Object.keys(data.hosts || {}).forEach(host => { 371 | if (host !== 'local') { 372 | const hostInfo = data.hosts[host]; 373 | const option = document.createElement('option'); 374 | option.value = hostInfo.url || ''; 375 | option.textContent = host; 376 | instanceSelector.appendChild(option); 377 | } 378 | }); 379 | } 380 | }) 381 | .catch(error => { 382 | console.error('Failed to load hosts:', error); 383 | }); 384 | } 385 | 386 | // Load and display hosts list (legacy compatibility) 387 | function loadHostsList() { 388 | loadHostsManagement(); 389 | } 390 | 391 | 392 | 393 | // Analyze volume mappings 394 | function analyzeVolumes(composeData) { 395 | const volumeWarning = document.getElementById('volume-warning'); 396 | const volumePathsList = document.getElementById('volume-paths-list'); 397 | 398 | if (!volumeWarning || !volumePathsList || !composeData.services) { 399 | return; 400 | } 401 | 402 | const hostPaths = new Set(); 403 | 404 | // Check each service for volume mappings 405 | Object.values(composeData.services).forEach(service => { 406 | if (service.volumes && Array.isArray(service.volumes)) { 407 | service.volumes.forEach(volume => { 408 | if (typeof volume === 'string' && volume.includes(':')) { 409 | const hostPath = volume.split(':')[0]; 410 | 411 | // Only warn about absolute host paths (not named volumes or relative paths) 412 | if (hostPath.startsWith('/') || hostPath.match(/^[A-Za-z]:\//)) { 413 | hostPaths.add(hostPath); 414 | } 415 | } 416 | }); 417 | } 418 | }); 419 | 420 | if (hostPaths.size > 0) { 421 | volumePathsList.innerHTML = Array.from(hostPaths) 422 | .map(path => `
  • ${path}
  • `) 423 | .join(''); 424 | volumeWarning.style.display = 'block'; 425 | } else { 426 | volumeWarning.style.display = 'none'; 427 | } 428 | } 429 | 430 | // Analyze network requirements 431 | function analyzeNetworks(composeData) { 432 | const networkWarning = document.getElementById('network-warning'); 433 | const networkList = document.getElementById('network-list'); 434 | 435 | if (!networkWarning || !networkList) { 436 | return; 437 | } 438 | 439 | const externalNetworks = new Set(); 440 | 441 | // Check for external networks 442 | if (composeData.networks) { 443 | Object.entries(composeData.networks).forEach(([networkName, networkConfig]) => { 444 | if (networkConfig && networkConfig.external) { 445 | externalNetworks.add(networkName); 446 | } 447 | }); 448 | } 449 | 450 | // Check services for network references 451 | if (composeData.services) { 452 | Object.values(composeData.services).forEach(service => { 453 | if (service.networks && Array.isArray(service.networks)) { 454 | service.networks.forEach(network => { 455 | if (typeof network === 'string' && !['default', 'bridge', 'host', 'none'].includes(network)) { 456 | // This might be an external network 457 | externalNetworks.add(network); 458 | } 459 | }); 460 | } 461 | }); 462 | } 463 | 464 | if (externalNetworks.size > 0) { 465 | networkList.innerHTML = Array.from(externalNetworks) 466 | .map(network => `
  • ${network}
  • `) 467 | .join(''); 468 | networkWarning.style.display = 'block'; 469 | } else { 470 | networkWarning.style.display = 'none'; 471 | } 472 | } 473 | 474 | // Hide all warning boxes 475 | function hideAllWarnings() { 476 | const volumeWarning = document.getElementById('volume-warning'); 477 | const networkWarning = document.getElementById('network-warning'); 478 | 479 | if (volumeWarning) volumeWarning.style.display = 'none'; 480 | if (networkWarning) networkWarning.style.display = 'none'; 481 | } 482 | 483 | // Show deployment log in modal 484 | function showDeploymentLog(action, host, output) { 485 | const modal = document.createElement('div'); 486 | modal.className = 'logs-modal deployment-log-modal'; 487 | modal.innerHTML = ` 488 | 492 |
    493 |
    ${output}
    494 |
    495 | `; 496 | document.body.appendChild(modal); 497 | } 498 | 499 | // Validate compose file YAML syntax 500 | function validateComposeFile() { 501 | const content = window.getCodeMirrorContent ? 502 | window.getCodeMirrorContent('compose-editor') : 503 | document.getElementById('compose-editor').value; 504 | 505 | if (!content.trim()) { 506 | showMessage('warning', 'Compose file is empty'); 507 | return; 508 | } 509 | 510 | try { 511 | const parsed = jsyaml.load(content); 512 | 513 | // Basic validation 514 | if (!parsed) { 515 | throw new Error('Empty YAML document'); 516 | } 517 | 518 | if (!parsed.services) { 519 | throw new Error('No services defined in compose file'); 520 | } 521 | 522 | if (Object.keys(parsed.services).length === 0) { 523 | throw new Error('Services section is empty'); 524 | } 525 | 526 | showMessage('success', `✅ Valid compose file with ${Object.keys(parsed.services).length} services`); 527 | 528 | } catch (error) { 529 | showMessage('error', `❌ Invalid YAML: ${error.message}`); 530 | } 531 | } 532 | 533 | // Show compose file preview 534 | function showComposePreview() { 535 | const content = window.getCodeMirrorContent ? 536 | window.getCodeMirrorContent('compose-editor') : 537 | document.getElementById('compose-editor').value; 538 | 539 | if (!content.trim()) { 540 | showMessage('warning', 'Compose file is empty'); 541 | return; 542 | } 543 | 544 | try { 545 | const parsed = jsyaml.load(content); 546 | const serviceCount = parsed.services ? Object.keys(parsed.services).length : 0; 547 | const networkCount = parsed.networks ? Object.keys(parsed.networks).length : 0; 548 | const volumeCount = parsed.volumes ? Object.keys(parsed.volumes).length : 0; 549 | 550 | const modal = document.createElement('div'); 551 | modal.className = 'logs-modal'; 552 | modal.innerHTML = ` 553 | 557 | 576 | `; 577 | document.body.appendChild(modal); 578 | 579 | } catch (error) { 580 | showMessage('error', `Cannot preview: ${error.message}`); 581 | } 582 | } 583 | 584 | 585 | 586 | // Deploy created project to host 587 | async function deployProjectToHost(composePath, host, autoStart = true) { 588 | try { 589 | setLoading(true, `Deploying project to ${host}...`); 590 | 591 | const action = autoStart ? 'up' : 'down'; 592 | 593 | const response = await fetch('/api/compose/deploy', { 594 | method: 'POST', 595 | headers: { 'Content-Type': 'application/json' }, 596 | body: JSON.stringify({ 597 | file: composePath, 598 | host: host, 599 | action: action 600 | }) 601 | }); 602 | 603 | const result = await response.json(); 604 | 605 | if (result.status === 'success') { 606 | showMessage('success', `Project deployed successfully to ${host}`); 607 | 608 | // Refresh containers 609 | if (typeof refreshContainers === 'function') { 610 | setTimeout(() => refreshContainers(), 1000); 611 | } 612 | } else { 613 | showMessage('error', `Deployment failed: ${result.message}`); 614 | } 615 | 616 | } catch (error) { 617 | console.error('Deploy project error:', error); 618 | showMessage('error', `Failed to deploy project: ${error.message}`); 619 | } finally { 620 | setLoading(false); 621 | } 622 | } 623 | 624 | // Get host information 625 | async function getHostInfo(hostName) { 626 | try { 627 | const response = await fetch('/api/hosts'); 628 | const data = await response.json(); 629 | 630 | if (data.status === 'success' && data.hosts[hostName]) { 631 | return data.hosts[hostName]; 632 | } 633 | 634 | return { name: hostName, url: 'unknown' }; 635 | } catch (error) { 636 | return { name: hostName, url: 'unknown' }; 637 | } 638 | } 639 | 640 | // Save compose file if there are unsaved changes 641 | async function saveComposeIfNeeded() { 642 | // In a real implementation, you'd check if the file has been modified 643 | // For now, we'll just save it 644 | if (currentComposeFile) { 645 | await saveCompose(); 646 | } 647 | } 648 | 649 | // Utility function for debouncing 650 | function debounce(func, wait) { 651 | let timeout; 652 | return function executedFunction(...args) { 653 | const later = () => { 654 | clearTimeout(timeout); 655 | func(...args); 656 | }; 657 | clearTimeout(timeout); 658 | timeout = setTimeout(later, wait); 659 | }; 660 | } 661 | 662 | 663 | 664 | 665 | // Initialize hosts management when DOM loads 666 | document.addEventListener('DOMContentLoaded', () => { 667 | // Load hosts management if on hosts tab 668 | const hostsTab = document.getElementById('hosts-tab'); 669 | if (hostsTab && hostsTab.classList.contains('active')) { 670 | loadHostsManagement(); 671 | } 672 | }); 673 | 674 | // Export functions for use in main.js 675 | window.loadDockerHosts = loadDockerHosts; 676 | window.switchToHost = switchToHost; 677 | window.loadHostsList = loadHostsList; 678 | window.addDockerHost = addDockerHost; 679 | window.removeHost = removeHost; 680 | window.testHost = testHost; 681 | window.loadHostsManagement = loadHostsManagement; 682 | window.loadMultiHostSystemOverview = loadMultiHostSystemOverview; 683 | // Export functions for global access 684 | 685 | 686 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 242 | 243 | 244 | 245 | Composr 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 273 | 274 | 275 |
    276 |
    277 | 278 |
    279 |
    280 | Containers: -- 281 | Hosts: -- 282 | CPU: -- cores 283 | Memory: --/-- GB 284 |
    285 |
    286 |
    287 | Containers: -- (-- running) 288 |
    289 |
    290 | Connected Hosts: -- 291 |
    292 |
    293 | Total CPU: -- cores 294 |
    295 |
    296 | Total Memory: -- GB 297 |
    298 |
    299 |
    300 | 301 |
    302 | 303 | 306 | 309 | 315 | 320 | 326 | 327 | 333 | 334 |
    335 |
    336 | 337 |
    338 | 342 | 346 | 350 | 354 | 358 |
    359 |
    360 | 361 | 364 | 365 | 366 | 367 | 368 | 369 |
    370 | 373 | 374 | 377 | 378 | 383 | 384 | 389 | 390 | 397 |
    398 | 399 | 400 |
    401 | 402 | 403 | 404 | 405 |
    406 |
    407 | 408 |
    409 |
    410 | 411 | 412 | 413 | 414 |
    415 |
    416 |
    417 |
    418 | 419 | 420 |
    421 | 422 |
    423 |
    424 |
    425 | 426 |
    427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 |
    NameStackStatusUptimePortsActions
    445 |
    446 |
    447 | 448 |
    449 |
    450 |

    🖥️ Docker Host Management

    451 | 452 | 453 | 454 | 455 |
    456 |

    Connected Hosts

    457 |
    458 | 459 |
    460 |
    461 | 462 | 463 |
    464 |

    Add New Docker Host

    465 |
    466 | 467 | 468 |
    469 |
    470 | 471 | 472 |
    473 |
    474 | 475 | 476 |
    477 |
    478 | 479 | 480 |
    481 |
    482 | 483 | 484 |
    485 |

    🔗 Docker Host Setup

    486 |
    487 |
    Enable Docker Remote API:
    488 |
    489 |
    Method 1: Docker Daemon Configuration
    490 |
    # Edit /etc/docker/daemon.json
    491 |         {
    492 |         "hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2375"]
    493 |         }
    494 | 
    495 |         # Restart Docker
    496 |         sudo systemctl restart docker
    497 |
    498 |
    499 |
    Method 2: Docker Compose Override
    500 |
    # Add to docker-compose.yml
    501 |         services:
    502 |         composr:
    503 |             environment:
    504 |             - DOCKER_HOSTS=local=unix:///var/run/docker.sock,prod=tcp://192.168.1.100:2375
    505 |
    506 |
    507 |
    ⚠️ Security Warning
    508 |

    Only enable the Docker remote API on trusted networks. Consider using TLS certificates for production environments.

    509 |
    510 |
    511 |
    512 |
    513 |
    514 | 515 | 516 |
    517 |
    518 | 519 | 520 | 521 | 522 |
    523 | 524 | 525 | 526 |
    527 |
    528 | 531 | 532 |
    533 | 534 | 535 |
    536 | 537 |
    538 | 539 | 542 | 543 |
    544 | 545 | 546 | 547 | 548 | 551 |
    552 |
    553 | 554 | 555 |
    556 |
    557 | 560 | 561 |
    562 | 563 |
    564 | 565 |
    566 |
    567 | 568 | 569 |
    570 |
    571 | 572 |
    573 | 574 |
    575 |
    576 |
    1
    577 |

    Project Information

    578 |
    579 |
    580 | 581 | 582 |
    583 |
    584 | 585 | 588 |

    The project will be created as a new directory in the selected location

    589 |
    590 | 594 | 595 |
    596 | 597 | 598 |
    599 |
    600 | 601 | 602 | 603 |
    604 |
    605 | 606 | 634 | 635 | 636 |
    637 |
    638 |
    2
    639 |

    Review & Create

    640 |
    641 |
    642 |

    Project project-name will be created in default directory.

    643 | 644 |
    645 |

    📄 docker-compose.yml

    646 |
    647 |
    648 | 649 | 653 |
    654 |
    655 |

    🚀 Deployment Options

    656 | 657 |
    658 | 659 | 664 | 665 |
    666 | 667 | 668 | 669 |
    670 | Note: If deploying to a remote host, ensure any volume paths exist on that host. 671 |
    672 |
    673 | 674 |
    675 | 676 | 677 | 678 |
    679 |
    680 |
    681 |
    682 |
    683 | 684 | 685 |
    686 | 687 |
    688 | 689 | 690 |
    691 |
    692 |
    693 | 694 | 695 |
    696 |
    697 |

    Docker Images

    698 |
    699 | 700 | 701 | 702 |
    703 |
    704 |
    705 |
    706 |
    707 |
    708 | 709 | 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 |
    Image NameTagsSizeCreatedUsed ByActions
    721 |
    722 |
    723 | 724 |
    725 |
    726 |
    727 | 728 |
    729 |

    📦 Create Backup

    730 | 731 |
    732 |
    733 |
    Loading...
    734 |
    735 |
    736 | 737 |
    738 |
    739 | 740 | 742 |
    743 | 744 |
    745 |
    746 | 750 | 754 |
    755 |
    756 | 757 |
    758 | 761 |
    762 |
    763 |
    764 | 765 | 766 |
    767 |

    📂 Restore Backup

    768 | 769 |
    770 |
    771 | 773 | 776 | 777 |
    778 | 779 |
    780 | 783 |
    784 |
    785 | 786 |
    787 |

    Restores: Compose files, .env files, container tags & URLs

    788 |

    Note: Volume data not included - backup separately

    789 |
    790 |
    791 |
    792 | 793 | 794 |
    795 |
    796 |
    Loading history...
    797 |
    798 |
    799 |
    800 | 801 | 802 | 803 | 804 | 805 | 806 | 807 | 808 | 809 | 810 | 811 | --------------------------------------------------------------------------------