Compare commits

..

3 Commits

Author SHA1 Message Date
Bas Nijholt
3c1cc79684 refactor(docker): Use multi-stage build to reduce image size
Reduces image size from 880MB to 139MB (84% smaller) by:
- Building with uv in a separate stage
- Using python:3.14-alpine as runtime base (no uv overhead)
- Pre-compiling bytecode with --compile-bytecode
- Copying only the tool virtualenv and bin symlinks to runtime
2025-12-18 00:58:06 -08:00
Bas Nijholt
12bbcee374 feat(web): Handle invalid config gracefully with error banner (#16) 2025-12-18 00:40:19 -08:00
Bas Nijholt
6e73ae0157 feat(web): Add command palette with Cmd+K (#15) 2025-12-18 00:12:38 -08:00
13 changed files with 262 additions and 58 deletions

View File

@@ -1,16 +1,20 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.14-alpine
# Install SSH client (required for remote host connections)
# Build stage - install with uv
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS builder
ARG VERSION
RUN uv tool install --compile-bytecode "compose-farm[web]${VERSION:+==$VERSION}"
# Runtime stage - minimal image without uv
FROM python:3.14-alpine
# Install only runtime requirements
RUN apk add --no-cache openssh-client
# Install compose-farm from PyPI
ARG VERSION
RUN uv tool install "compose-farm[web]${VERSION:+==$VERSION}"
# Copy installed tool virtualenv and bin symlinks from builder
COPY --from=builder /root/.local/share/uv/tools/compose-farm /root/.local/share/uv/tools/compose-farm
COPY --from=builder /usr/local/bin/cf /usr/local/bin/compose-farm /usr/local/bin/
# Add uv tool bin to PATH
ENV PATH="/root/.local/bin:$PATH"
# Default entrypoint
ENTRYPOINT ["cf"]
CMD ["--help"]

View File

@@ -15,7 +15,7 @@ import typer
from compose_farm.cli.app import app
from compose_farm.console import console, err_console
from compose_farm.paths import config_search_paths, default_config_path
from compose_farm.paths import config_search_paths, default_config_path, find_config_path
config_app = typer.Typer(
name="config",
@@ -76,18 +76,8 @@ def _get_config_file(path: Path | None) -> Path | None:
if path:
return path.expanduser().resolve()
# Check environment variable
if env_path := os.environ.get("CF_CONFIG"):
p = Path(env_path)
if p.exists():
return p.resolve()
# Check standard locations
for p in config_search_paths():
if p.exists():
return p.resolve()
return None
config_path = find_config_path()
return config_path.resolve() if config_path else None
@config_app.command("init")

View File

@@ -3,13 +3,12 @@
from __future__ import annotations
import getpass
import os
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, model_validator
from .paths import xdg_config_home
from .paths import config_search_paths, find_config_path
class Host(BaseModel):
@@ -150,24 +149,10 @@ def load_config(path: Path | None = None) -> Config:
3. ./compose-farm.yaml
4. $XDG_CONFIG_HOME/compose-farm/compose-farm.yaml (defaults to ~/.config)
"""
search_paths = [
Path("compose-farm.yaml"),
xdg_config_home() / "compose-farm" / "compose-farm.yaml",
]
if path:
config_path = path
elif env_path := os.environ.get("CF_CONFIG"):
config_path = Path(env_path)
else:
config_path = None
for p in search_paths:
if p.exists():
config_path = p
break
config_path = path or find_config_path()
if config_path is None or not config_path.exists():
msg = f"Config file not found. Searched: {', '.join(str(p) for p in search_paths)}"
msg = f"Config file not found. Searched: {', '.join(str(p) for p in config_search_paths())}"
raise FileNotFoundError(msg)
if config_path.is_dir():

View File

@@ -19,3 +19,15 @@ def default_config_path() -> Path:
def config_search_paths() -> list[Path]:
"""Get search paths for config files."""
return [Path("compose-farm.yaml"), default_config_path()]
def find_config_path() -> Path | None:
"""Find the config file path, checking CF_CONFIG env var and search paths."""
if env_path := os.environ.get("CF_CONFIG"):
p = Path(env_path)
if p.exists() and p.is_file():
return p
for p in config_search_paths():
if p.exists() and p.is_file():
return p
return None

View File

@@ -3,11 +3,12 @@
from __future__ import annotations
import sys
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
@@ -19,8 +20,9 @@ if TYPE_CHECKING:
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
# Startup: pre-load config
get_config()
# Startup: pre-load config (ignore errors - handled per-request)
with suppress(ValidationError, FileNotFoundError):
get_config()
yield
# Shutdown: nothing to clean up

View File

@@ -7,13 +7,15 @@ import json
from typing import TYPE_CHECKING, Annotated, Any
import yaml
from fastapi import APIRouter, Body, HTTPException
from fastapi.responses import HTMLResponse
if TYPE_CHECKING:
from pathlib import Path
from fastapi import APIRouter, Body, HTTPException
from fastapi.responses import HTMLResponse
from compose_farm.executor import run_compose_on_host
from compose_farm.paths import find_config_path
from compose_farm.state import load_state
from compose_farm.web.deps import get_config, get_templates
@@ -200,12 +202,11 @@ async def save_config(
content: Annotated[str, Body(media_type="text/plain")],
) -> dict[str, Any]:
"""Save compose-farm.yaml config file."""
config = get_config()
if not config.config_path:
raise HTTPException(status_code=404, detail="Config path not set")
config_path = find_config_path()
if not config_path:
raise HTTPException(status_code=404, detail="Config file not found")
_validate_yaml(content)
config.config_path.write_text(content)
config_path.write_text(content)
return {"success": True, "message": "Config saved"}

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
import yaml
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import ValidationError
from compose_farm.paths import find_config_path
from compose_farm.state import (
get_orphaned_services,
get_service_host,
@@ -21,9 +23,41 @@ router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Dashboard page - combined view of all cluster info."""
config = get_config()
templates = get_templates()
# Try to load config, handle errors gracefully
config_error = None
try:
config = get_config()
except (ValidationError, FileNotFoundError) as e:
# Extract error message
if isinstance(e, ValidationError):
config_error = "; ".join(err.get("msg", str(err)) for err in e.errors())
else:
config_error = str(e)
# Read raw config content for the editor
config_path = find_config_path()
config_content = config_path.read_text() if config_path else ""
return templates.TemplateResponse(
"index.html",
{
"request": request,
"config_error": config_error,
"hosts": {},
"services": {},
"config_content": config_content,
"state_content": "",
"running_count": 0,
"stopped_count": 0,
"orphaned": [],
"migrations": [],
"not_started": [],
"services_by_host": {},
},
)
# Get state
deployed = load_state(config)
@@ -57,6 +91,7 @@ async def index(request: Request) -> HTMLResponse:
"index.html",
{
"request": request,
"config_error": None,
# Config data
"hosts": config.hosts,
"services": config.services,
@@ -143,6 +178,23 @@ async def sidebar_partial(request: Request) -> HTMLResponse:
)
@router.get("/partials/config-error", response_class=HTMLResponse)
async def config_error_partial(request: Request) -> HTMLResponse:
"""Config error banner partial."""
templates = get_templates()
try:
get_config()
return HTMLResponse("") # No error
except (ValidationError, FileNotFoundError) as e:
if isinstance(e, ValidationError):
error = "; ".join(err.get("msg", str(err)) for err in e.errors())
else:
error = str(e)
return templates.TemplateResponse(
"partials/config_error.html", {"request": request, "config_error": error}
)
@router.get("/partials/stats", response_class=HTMLResponse)
async def stats_partial(request: Request) -> HTMLResponse:
"""Stats cards partial."""

View File

@@ -179,6 +179,7 @@ function refreshDashboard() {
htmx.ajax('GET', '/partials/stats', {target: '#stats-cards', swap: 'outerHTML'});
htmx.ajax('GET', `/partials/pending?expanded=${isExpanded('pending-collapse')}`, {target: '#pending-operations', swap: 'outerHTML'});
htmx.ajax('GET', `/partials/services-by-host?expanded=${isExpanded('services-by-host-collapse')}`, {target: '#services-by-host', swap: 'outerHTML'});
htmx.ajax('GET', '/partials/config-error', {target: '#config-error', swap: 'innerHTML'});
}
/**
@@ -309,7 +310,7 @@ async function saveAllEditors() {
body: content
});
const data = await response.json();
if (!data.success) {
if (!response.ok || !data.success) {
results.push({ id, success: false, error: data.detail || 'Unknown error' });
} else {
results.push({ id, success: true });
@@ -320,13 +321,9 @@ async function saveAllEditors() {
}
// Show result
const errors = results.filter(r => !r.success);
if (errors.length > 0) {
alert('Errors saving:\n' + errors.map(e => `${e.id}: ${e.error}`).join('\n'));
} else if (saveBtn && results.length > 0) {
if (saveBtn && results.length > 0) {
saveBtn.textContent = 'Saved!';
setTimeout(() => saveBtn.textContent = saveBtn.id === 'save-config-btn' ? 'Save Config' : 'Save All', 2000);
refreshDashboard();
}
}
@@ -435,3 +432,115 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
// Not valid JSON, ignore
}
});
// Command Palette
(function() {
const dialog = document.getElementById('cmd-palette');
const input = document.getElementById('cmd-input');
const list = document.getElementById('cmd-list');
const fab = document.getElementById('cmd-fab');
if (!dialog || !input || !list) return;
const colors = { service: '#22c55e', action: '#eab308', nav: '#3b82f6' };
let commands = [];
let filtered = [];
let selected = 0;
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => window.location.href = url;
const cmd = (type, name, desc, action) => ({ type, name, desc, action });
function buildCommands() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', post('/api/apply')),
cmd('action', 'Refresh', 'Update state from reality', post('/api/refresh')),
cmd('nav', 'Dashboard', 'Go to dashboard', nav('/')),
];
// Add service-specific actions if on a service page
const match = window.location.pathname.match(/^\/service\/(.+)$/);
if (match) {
const svc = decodeURIComponent(match[1]);
const svcCmd = (name, desc, endpoint) => cmd('service', name, `${desc} ${svc}`, post(`/api/service/${svc}/${endpoint}`));
actions.unshift(
svcCmd('Up', 'Start', 'up'),
svcCmd('Down', 'Stop', 'down'),
svcCmd('Restart', 'Restart', 'restart'),
svcCmd('Pull', 'Pull', 'pull'),
svcCmd('Update', 'Pull + restart', 'update'),
svcCmd('Logs', 'View logs for', 'logs'),
);
}
// Add nav commands for all services from sidebar
const services = [...document.querySelectorAll('#sidebar-services li[data-svc] a[href]')].map(a => {
const name = a.getAttribute('href').replace('/service/', '');
return cmd('nav', name, 'Go to service', nav(`/service/${name}`));
});
commands = [...actions, ...services];
}
function filter() {
const q = input.value.toLowerCase();
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
function render() {
list.innerHTML = filtered.map((c, i) => `
<a class="flex justify-between items-center px-3 py-2 rounded-r cursor-pointer hover:bg-base-200 border-l-4 ${i === selected ? 'bg-base-300' : ''}" style="border-left-color: ${colors[c.type] || '#666'}" data-idx="${i}">
<span><span class="opacity-50 text-xs mr-2">${c.type}</span>${c.name}</span>
<span class="opacity-40 text-xs">${c.desc}</span>
</a>
`).join('') || '<div class="opacity-50 p-2">No matches</div>';
}
function open() {
buildCommands();
selected = 0;
input.value = '';
filter();
render();
dialog.showModal();
input.focus();
}
function exec() {
if (filtered[selected]) {
dialog.close();
filtered[selected].action();
}
}
// Keyboard: Cmd+K to open
document.addEventListener('keydown', e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
open();
}
});
// Input filtering
input.addEventListener('input', () => { filter(); render(); });
// Keyboard nav inside palette
dialog.addEventListener('keydown', e => {
if (!dialog.open) return;
if (e.key === 'ArrowDown') { e.preventDefault(); selected = Math.min(selected + 1, filtered.length - 1); render(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); }
else if (e.key === 'Enter') { e.preventDefault(); exec(); }
});
// Click to execute
list.addEventListener('click', e => {
const a = e.target.closest('a[data-idx]');
if (a) {
selected = parseInt(a.dataset.idx, 10);
exec();
}
});
// FAB click to open
if (fab) fab.addEventListener('click', open);
})();

View File

@@ -54,6 +54,9 @@
</div>
</div>
<!-- Command Palette -->
{% include "partials/command_palette.html" %}
<!-- Scripts - HTMX first -->
<script src="https://unpkg.com/htmx.org@2.0.4" data-vendor="htmx.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js" data-vendor="xterm.js"></script>

View File

@@ -19,8 +19,15 @@
{% include "partials/terminal.html" %}
<!-- Config Error Banner -->
<div id="config-error">
{% if config_error %}
{% include "partials/config_error.html" %}
{% endif %}
</div>
<!-- Config Editor -->
{% call collapse("Edit Config", badge="compose-farm.yaml", icon=settings()) %}
{% call collapse("Edit Config", badge="compose-farm.yaml", icon=settings(), checked=config_error) %}
<div class="editor-wrapper yaml-wrapper">
<div id="config-editor" class="yaml-editor" data-content="{{ config_content | e }}" data-save-url="/api/config"></div>
</div>

View File

@@ -0,0 +1,19 @@
{% from "partials/icons.html" import search, command %}
<dialog id="cmd-palette" class="modal">
<div class="modal-box max-w-lg p-0">
<label class="input input-lg bg-base-100 border-0 border-b border-base-300 w-full rounded-none rounded-t-box sticky top-0 z-10 focus-within:outline-none">
{{ search(20) }}
<input type="text" id="cmd-input" class="grow" placeholder="Type a command..." autocomplete="off" />
<kbd class="kbd kbd-sm opacity-50">esc</kbd>
</label>
<div id="cmd-list" class="flex flex-col p-2 max-h-80 overflow-y-auto">
<!-- Populated by JS -->
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Floating button to open command palette -->
<button id="cmd-fab" class="btn btn-circle glass shadow-lg fixed bottom-6 right-6 z-50 hover:ring hover:ring-base-content/50" title="Command Palette (⌘K)">
{{ command(24) }}
</button>

View File

@@ -0,0 +1,8 @@
{% from "partials/icons.html" import alert_triangle %}
<div class="alert alert-error mb-4">
{{ alert_triangle(size=20) }}
<div>
<h3 class="font-bold">Configuration Error</h3>
<div class="text-sm">{{ config_error }}</div>
</div>
</div>

View File

@@ -12,6 +12,12 @@
</svg>
{% endmacro %}
{% macro command(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>
</svg>
{% endmacro %}
{# Action icons #}
{% macro play(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -134,3 +140,9 @@
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
{% endmacro %}
{% macro alert_triangle(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>
</svg>
{% endmacro %}