Merge pull request #3 from TySP-Dev/claude/docker-compose-tui-C7Xl3
Enhance Docker update UI and functionality with new features
This commit is contained in:
+137
-47
@@ -24,12 +24,14 @@ from typing import Any
|
||||
# ── optional rich ──────────────────────────────────────────────────────────
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
)
|
||||
from rich.table import Table
|
||||
from rich.markup import escape as rich_escape
|
||||
@@ -73,7 +75,7 @@ OUTCOME_COLORS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# ── subprocess helper ──────────────────────────────────────────────────────
|
||||
# ── subprocess helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
@@ -81,12 +83,25 @@ def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[
|
||||
)
|
||||
|
||||
|
||||
def _get_compose_cmd() -> list[str]:
|
||||
"""Detect docker compose v2 plugin or legacy docker-compose."""
|
||||
if _run(["docker", "compose", "version"]).returncode == 0:
|
||||
return ["docker", "compose"]
|
||||
if shutil.which("docker-compose"):
|
||||
return ["docker-compose"]
|
||||
sys.exit("error: neither 'docker compose' nor 'docker-compose' found in PATH")
|
||||
|
||||
|
||||
def _is_git_repo(directory: str) -> bool:
|
||||
return _run(["git", "-C", directory, "rev-parse", "--is-inside-work-tree"]).returncode == 0
|
||||
|
||||
|
||||
# ── project discovery ──────────────────────────────────────────────────────
|
||||
|
||||
def discover_projects() -> list[dict[str, Any]]:
|
||||
def discover_projects(compose_cmd: list[str]) -> list[dict[str, Any]]:
|
||||
"""Return sorted list of compose projects found on this host."""
|
||||
try:
|
||||
r = _run(["docker", "compose", "ls", "--all", "--format", "json"])
|
||||
r = _run(compose_cmd + ["ls", "--all", "--format", "json"])
|
||||
except FileNotFoundError:
|
||||
sys.exit("error: 'docker' not found in PATH")
|
||||
|
||||
@@ -108,7 +123,7 @@ def discover_projects() -> list[dict[str, Any]]:
|
||||
projects.append({
|
||||
"name": name,
|
||||
"workdir": workdir,
|
||||
"is_git": Path(workdir, ".git").is_dir(),
|
||||
"is_git": _is_git_repo(workdir),
|
||||
"status": item.get("Status", ""),
|
||||
})
|
||||
|
||||
@@ -122,17 +137,17 @@ def _container_logs(workdir: str) -> str:
|
||||
return (r.stdout or r.stderr or "(no logs available)").strip()
|
||||
|
||||
|
||||
def update_project_git(project: dict[str, Any]) -> dict[str, Any]:
|
||||
def update_project_git(project: dict[str, Any], compose_cmd: list[str]) -> dict[str, Any]:
|
||||
workdir = project["workdir"]
|
||||
log_lines: list[str] = []
|
||||
|
||||
r = _run(["git", "pull"], cwd=workdir)
|
||||
r = _run(["git", "-C", workdir, "pull"])
|
||||
log_lines.append(f"=== git pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
|
||||
pull_ok = r.returncode == 0
|
||||
|
||||
r = _run(["docker", "compose", "up", "-d", "--build"], cwd=workdir)
|
||||
r = _run(compose_cmd + ["up", "-d", "--build"], cwd=workdir)
|
||||
log_lines.append(
|
||||
f"=== docker compose up -d --build (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
|
||||
f"=== up -d --build (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
|
||||
)
|
||||
|
||||
if r.returncode != 0:
|
||||
@@ -148,27 +163,27 @@ def update_project_git(project: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**project, "outcome": outcome, "log_lines": log_lines}
|
||||
|
||||
|
||||
def update_project_registry(project: dict[str, Any]) -> dict[str, Any]:
|
||||
def update_project_registry(project: dict[str, Any], compose_cmd: list[str]) -> dict[str, Any]:
|
||||
workdir = project["workdir"]
|
||||
log_lines: list[str] = []
|
||||
|
||||
r = _run(["docker", "compose", "down"], cwd=workdir)
|
||||
log_lines.append(f"=== docker compose down (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
|
||||
r = _run(compose_cmd + ["down"], cwd=workdir)
|
||||
log_lines.append(f"=== down (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
|
||||
if r.returncode != 0:
|
||||
log_lines.append(f"\nStep 'stop' failed (exit {r.returncode}):")
|
||||
log_lines.append(r.stderr)
|
||||
return {**project, "outcome": FAILED_STOP, "log_lines": log_lines}
|
||||
|
||||
r = _run(["docker", "compose", "pull"], cwd=workdir)
|
||||
log_lines.append(f"=== docker compose pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
|
||||
r = _run(compose_cmd + ["pull"], cwd=workdir)
|
||||
log_lines.append(f"=== pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
|
||||
pull_ok = r.returncode == 0
|
||||
if not pull_ok:
|
||||
log_lines.append(f"\nStep 'pull' failed (exit {r.returncode}) — restarting with existing image:")
|
||||
log_lines.append(r.stderr)
|
||||
|
||||
r = _run(["docker", "compose", "up", "-d"], cwd=workdir)
|
||||
r = _run(compose_cmd + ["up", "-d"], cwd=workdir)
|
||||
log_lines.append(
|
||||
f"=== docker compose up -d (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
|
||||
f"=== up -d (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
|
||||
)
|
||||
|
||||
if r.returncode != 0:
|
||||
@@ -186,48 +201,119 @@ def update_project_registry(project: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
# ── rich runner ────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_update_rich(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _run_update_rich(projects: list[dict[str, Any]], compose_cmd: list[str]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
git_count = sum(1 for p in projects if p["is_git"])
|
||||
reg_count = len(projects) - git_count
|
||||
|
||||
console.print(Panel.fit(
|
||||
f"[bold cyan]docker-update[/bold cyan] "
|
||||
f"[dim]{len(projects)} project(s) · {git_count} git · {reg_count} registry[/dim]",
|
||||
border_style="cyan",
|
||||
))
|
||||
console.print()
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[bold]{task.description:<32}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(bar_width=30),
|
||||
TaskProgressColumn(),
|
||||
transient=False,
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
) as prog:
|
||||
task = prog.add_task("Updating stacks…", total=len(projects))
|
||||
transient=False,
|
||||
) as progress:
|
||||
overall = progress.add_task("[bold white]Overall", total=len(projects))
|
||||
|
||||
for project in projects:
|
||||
prog.update(task, description=f" {project['name']}")
|
||||
result = (
|
||||
update_project_git(project)
|
||||
if project["is_git"]
|
||||
else update_project_registry(project)
|
||||
name = project["name"]
|
||||
workdir = project["workdir"]
|
||||
is_git = project["is_git"]
|
||||
mode_tag = "[magenta]git[/magenta]" if is_git else "[blue]reg[/blue]"
|
||||
n_steps = 2 if is_git else 3
|
||||
|
||||
task = progress.add_task(
|
||||
f"[cyan]{name}[/cyan] {mode_tag}",
|
||||
total=n_steps,
|
||||
)
|
||||
results.append(result)
|
||||
prog.advance(task)
|
||||
color = OUTCOME_COLORS[result["outcome"]]
|
||||
icon = OUTCOME_ICONS[result["outcome"]]
|
||||
prog.console.print(
|
||||
f" [{color}]{icon}[/{color}] {rich_escape(result['name'])}"
|
||||
|
||||
if is_git:
|
||||
step_cmds = [
|
||||
("git pull", ["git", "-C", workdir, "pull"]),
|
||||
("up --build", compose_cmd + ["up", "-d", "--build"]),
|
||||
]
|
||||
else:
|
||||
step_cmds = [
|
||||
("down", compose_cmd + ["down"]),
|
||||
("pull", compose_cmd + ["pull"]),
|
||||
("up", compose_cmd + ["up", "-d"]),
|
||||
]
|
||||
|
||||
steps: dict[str, dict[str, Any]] = {}
|
||||
pull_failed = False
|
||||
outcome = SUCCEEDED
|
||||
|
||||
for label, cmd in step_cmds:
|
||||
progress.update(
|
||||
task,
|
||||
description=f"[cyan]{name}[/cyan] {mode_tag} [yellow]{label}[/yellow]",
|
||||
)
|
||||
r = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True)
|
||||
rc, out, err = r.returncode, r.stdout, r.stderr
|
||||
steps[label] = {"rc": rc, "out": out, "err": err}
|
||||
progress.advance(task)
|
||||
|
||||
if rc != 0:
|
||||
if label in ("git pull", "pull"):
|
||||
pull_failed = True
|
||||
elif label == "down":
|
||||
outcome = FAILED_STOP
|
||||
break
|
||||
elif label in ("up", "up --build"):
|
||||
outcome = FAILED_RESTART
|
||||
break
|
||||
|
||||
if outcome == SUCCEEDED and pull_failed:
|
||||
outcome = FAILED_PULL
|
||||
|
||||
color = OUTCOME_COLORS[outcome]
|
||||
progress.update(
|
||||
task,
|
||||
description=f"[{color}]{OUTCOME_ICONS[outcome]} {name}[/{color}] {mode_tag}",
|
||||
)
|
||||
|
||||
# Build log_lines for the text log writer
|
||||
log_lines: list[str] = []
|
||||
for step_label, sd in steps.items():
|
||||
log_lines.append(
|
||||
f"=== {step_label} (exit {sd['rc']}) ===\n{sd['out']}{sd['err']}"
|
||||
)
|
||||
if outcome != SUCCEEDED:
|
||||
log_lines.append(
|
||||
f"\nContainer logs ({name}):\n{_container_logs(workdir)}"
|
||||
)
|
||||
|
||||
results.append({**project, "outcome": outcome, "log_lines": log_lines})
|
||||
progress.advance(overall)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── plain runner ───────────────────────────────────────────────────────────
|
||||
|
||||
def _run_update_plain(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
def _run_update_plain(projects: list[dict[str, Any]], compose_cmd: list[str]) -> list[dict[str, Any]]:
|
||||
total = len(projects)
|
||||
git_count = sum(1 for p in projects if p["is_git"])
|
||||
reg_count = total - git_count
|
||||
print(f"docker-update — {total} project(s) · {git_count} git · {reg_count} registry\n")
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for i, project in enumerate(projects, 1):
|
||||
print(f"[{i}/{total}] Updating {project['name']}…", flush=True)
|
||||
mode = "git" if project["is_git"] else "registry"
|
||||
print(f"[{i}/{total}] {project['name']} ({mode}) — updating…", flush=True)
|
||||
result = (
|
||||
update_project_git(project)
|
||||
update_project_git(project, compose_cmd)
|
||||
if project["is_git"]
|
||||
else update_project_registry(project)
|
||||
else update_project_registry(project, compose_cmd)
|
||||
)
|
||||
results.append(result)
|
||||
icon = OUTCOME_ICONS[result["outcome"]]
|
||||
@@ -249,19 +335,17 @@ def _group_by_outcome(
|
||||
|
||||
|
||||
def _print_summary_rich(groups: dict[str, list[dict[str, Any]]]) -> None:
|
||||
console.print()
|
||||
console.rule("[bold]Summary")
|
||||
console.print()
|
||||
parts: list[str] = []
|
||||
if groups[SUCCEEDED]:
|
||||
parts.append(f"[green]✓ {len(groups[SUCCEEDED])} succeeded[/green]")
|
||||
parts.append(f"[bold green]✓ {len(groups[SUCCEEDED])} succeeded[/bold green]")
|
||||
if groups[FAILED_PULL]:
|
||||
parts.append(f"[yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/yellow]")
|
||||
parts.append(f"[bold yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/bold yellow]")
|
||||
if groups[FAILED_STOP]:
|
||||
parts.append(f"[red]✗ {len(groups[FAILED_STOP])} failed to stop[/red]")
|
||||
parts.append(f"[bold red]✗ {len(groups[FAILED_STOP])} failed to stop[/bold red]")
|
||||
if groups[FAILED_RESTART]:
|
||||
parts.append(f"[red]✗ {len(groups[FAILED_RESTART])} failed to restart[/red]")
|
||||
console.print(" " + " ".join(parts))
|
||||
parts.append(f"[bold red]✗ {len(groups[FAILED_RESTART])} failed to restart[/bold red]")
|
||||
console.print(" ".join(parts))
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -347,12 +431,17 @@ def cmd_update() -> None:
|
||||
log_path = LOG_DIR / f"docker-update-{run_id}.log"
|
||||
json_path = LOG_DIR / f"docker-update-{run_id}.json"
|
||||
|
||||
projects = discover_projects()
|
||||
compose_cmd = _get_compose_cmd()
|
||||
projects = discover_projects(compose_cmd)
|
||||
if not projects:
|
||||
print("No Docker Compose projects found.")
|
||||
return
|
||||
|
||||
results = _run_update_rich(projects) if HAS_RICH else _run_update_plain(projects)
|
||||
results = (
|
||||
_run_update_rich(projects, compose_cmd)
|
||||
if HAS_RICH
|
||||
else _run_update_plain(projects, compose_cmd)
|
||||
)
|
||||
|
||||
groups = _group_by_outcome(results)
|
||||
_print_summary_rich(groups) if HAS_RICH else _print_summary_plain(groups)
|
||||
@@ -424,7 +513,8 @@ def _cmd_log_plain() -> None:
|
||||
|
||||
def cmd_debug() -> None:
|
||||
"""Dry-run: discover projects and show what would happen, no changes."""
|
||||
projects = discover_projects()
|
||||
compose_cmd = _get_compose_cmd()
|
||||
projects = discover_projects(compose_cmd)
|
||||
if not projects:
|
||||
print("No Docker Compose projects found.")
|
||||
return
|
||||
|
||||
+44
-24
@@ -15,7 +15,7 @@ _DU_TMPDIR=""
|
||||
|
||||
_setup_tmpdir() {
|
||||
_DU_TMPDIR=$(mktemp -d /tmp/du-fzf-XXXXXX)
|
||||
trap '_cleanup' EXIT
|
||||
trap '_cleanup' EXIT INT TERM
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
@@ -27,6 +27,19 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── project status (by name, no workdir needed) ────────────────────────────
|
||||
|
||||
# Returns "running" or "stopped" for a compose project by name.
|
||||
# Uses docker ps --filter so it works from any directory.
|
||||
project_status_by_name() {
|
||||
local name="$1"
|
||||
local running
|
||||
running=$(docker ps -q \
|
||||
--filter "label=com.docker.compose.project=${name}" \
|
||||
2>/dev/null | wc -l)
|
||||
[[ "$running" -gt 0 ]] && echo "running" || echo "stopped"
|
||||
}
|
||||
|
||||
# ── build_log_list ────────────────────────────────────────────────────────
|
||||
# Outputs tab-delimited lines:
|
||||
# DISPLAY_TEXT \t FULL_LOG_PATH
|
||||
@@ -138,29 +151,44 @@ PYEOF
|
||||
}
|
||||
|
||||
# ── write_preview_script ──────────────────────────────────────────────────
|
||||
# Writes a small bash script to $_DU_TMPDIR/preview.sh that fzf can call.
|
||||
# The script receives the full tab-delimited line as $1 and streams docker logs.
|
||||
# Writes a bash script to $_DU_TMPDIR/preview.sh that fzf calls on hover.
|
||||
# Uses docker ps --filter label=... so it works from any directory.
|
||||
write_preview_script() {
|
||||
local script="$_DU_TMPDIR/preview.sh"
|
||||
cat > "$script" << 'PREVIEW_EOF'
|
||||
#!/bin/bash
|
||||
line="$1"
|
||||
name=$(printf '%s' "$line" | cut -f2)
|
||||
workdir=$(printf '%s' "$line" | cut -f3)
|
||||
|
||||
if [[ "$name" == "HEADER" || -z "$workdir" ]]; then
|
||||
if [[ "$name" == "HEADER" || -z "$name" ]]; then
|
||||
printf '\n (hover over a project to see its live container logs)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -d "$workdir" ]]; then
|
||||
printf 'Workdir not found: %s\n' "$workdir"
|
||||
exit 1
|
||||
printf '\033[1m── containers: %s ──\033[0m\n' "$name"
|
||||
docker ps \
|
||||
--filter "label=com.docker.compose.project=${name}" \
|
||||
--format ' {{.Names}} {{.Status}}' 2>/dev/null
|
||||
printf '\n\033[1m── logs (last 40 lines) ──\033[0m\n'
|
||||
|
||||
# Grab up to 3 container IDs for this project and tail their logs
|
||||
containers=$(docker ps -q \
|
||||
--filter "label=com.docker.compose.project=${name}" \
|
||||
2>/dev/null | head -3)
|
||||
|
||||
if [[ -z "$containers" ]]; then
|
||||
# Also check stopped containers
|
||||
containers=$(docker ps -aq \
|
||||
--filter "label=com.docker.compose.project=${name}" \
|
||||
2>/dev/null | head -3)
|
||||
[[ -z "$containers" ]] && { printf ' (no containers found)\n'; exit 0; }
|
||||
fi
|
||||
|
||||
printf '\033[1m── live logs: %s ──\033[0m\n\n' "$name"
|
||||
cd "$workdir" && docker compose logs --tail=80 --no-color 2>&1 \
|
||||
|| printf 'No logs available\n'
|
||||
printf '%s\n' "$containers" | while IFS= read -r cid; do
|
||||
cname=$(docker inspect --format '{{.Name}}' "$cid" 2>/dev/null | sed 's|^/||')
|
||||
printf '\n\033[2m--- %s ---\033[0m\n' "$cname"
|
||||
docker logs --tail=40 --timestamps "$cid" 2>&1
|
||||
done
|
||||
PREVIEW_EOF
|
||||
chmod +x "$script"
|
||||
echo "$script"
|
||||
@@ -189,22 +217,14 @@ LOGPREVIEW_EOF
|
||||
show_action_menu() {
|
||||
local name="$1" workdir="$2" is_git="$3" is_debug="$4"
|
||||
|
||||
# Live status check
|
||||
local running_count stopped_count
|
||||
running_count=$(cd "$workdir" 2>/dev/null \
|
||||
&& docker compose ps -q --status running 2>/dev/null | wc -l \
|
||||
|| echo 0)
|
||||
stopped_count=$(cd "$workdir" 2>/dev/null \
|
||||
&& docker compose ps -q 2>/dev/null | wc -l \
|
||||
|| echo 0)
|
||||
|
||||
local dot="○"
|
||||
[[ "$running_count" -gt 0 ]] && dot="●"
|
||||
local status dot
|
||||
status=$(project_status_by_name "$name")
|
||||
[[ "$status" == "running" ]] && dot="●" || dot="○"
|
||||
|
||||
local actions=""
|
||||
[[ "$running_count" -eq 0 ]] && actions+=" ▶ Start\n"
|
||||
[[ "$status" != "running" ]] && actions+=" ▶ Start\n"
|
||||
actions+=" ↺ Update\n"
|
||||
[[ "$running_count" -gt 0 ]] && actions+=" ■ Stop\n"
|
||||
[[ "$status" == "running" ]] && actions+=" ■ Stop\n"
|
||||
|
||||
local debug_note=""
|
||||
[[ "$is_debug" == "true" ]] && debug_note=" \033[33m[DEBUG — no real changes]\033[0m"
|
||||
|
||||
Reference in New Issue
Block a user