mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-11 09:24:29 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1cc79684 | ||
|
|
12bbcee374 | ||
|
|
6e73ae0157 |
22
Dockerfile
22
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
src/compose_farm/web/templates/partials/command_palette.html
Normal file
19
src/compose_farm/web/templates/partials/command_palette.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user