├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
179 |
180 |
Backup Information
181 |
Name: ${result.backup_info.name}
182 |
Created: ${new Date(result.backup_info.created).toLocaleString()}
183 |
Original Host: ${result.backup_info.host}
184 |
Containers: ${result.backup_info.container_count}
185 |
186 |
187 |
188 |
Restored Items
189 |
190 | - ${result.restored.compose_files} compose files
191 | - ${result.restored.env_files} environment files
192 | - ${result.restored.container_metadata} container settings (tags & URLs)
193 |
194 |
195 |
196 |
197 |
Next Steps
198 |
Your backup has been restored to the compose directory. You can now:
199 |
200 | - Go to the Config tab to review restored compose files
201 | - Deploy individual projects using the compose files
202 | - Or deploy everything at once using the backup compose file
203 |
204 |
205 |
206 |
207 |
210 |
213 |
214 |
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 | 
2 |
3 | # Composr - Docker Compose Companion
4 |
5 | [](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 | 
422 | 
423 | 
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 |
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 |
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 |
558 |
559 |
Summary
560 |
561 | 📦 ${serviceCount} Services
562 | 🌐 ${networkCount} Networks
563 | 💾 ${volumeCount} Volumes
564 |
565 |
566 | ${serviceCount > 0 ? `
567 |
Services:
568 |
569 | ${Object.entries(parsed.services).map(([name, service]) =>
570 | `- ${name} - ${service.image || 'No image specified'}
`
571 | ).join('')}
572 |
573 | ` : ''}
574 |
575 |
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 |
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 |
No containers found.
422 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
🖥️ Docker Host Management
451 |
452 |
453 |
454 |
455 |
456 |
Connected Hosts
457 |
458 |
459 |
460 |
461 |
462 |
463 |
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 |
683 |
684 |
685 |
686 |
687 |
688 |
689 |
690 |
691 |
692 |
693 |
694 |
695 |
696 |
704 |
707 |
708 |
709 |
710 |
711 | | Image Name |
712 | Tags |
713 | Size |
714 | Created |
715 | Used By |
716 | Actions |
717 |
718 |
719 |
720 |
721 |
722 |
723 |
724 |
725 |
726 |
727 |
728 |
729 |
📦 Create Backup
730 |
731 |
736 |
737 |
763 |
764 |
765 |
766 |
767 |
📂 Restore Backup
768 |
769 |
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 |
--------------------------------------------------------------------------------