From 61b472bbe81d00d0c3ef7a98470dbbebbd168c7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 10:51:20 +0000 Subject: [PATCH] Swap in richer progress UI; fix fzf preview to use docker label filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-update (Python): - Add compose_binary detection (docker compose v2 plugin or legacy docker-compose) - is_git_repo() now uses `git -C dir rev-parse` instead of .git dir check - Rich runner: Panel header showing project count / git / registry split, per-project sub-task with step labels (git pull, down, pull, up --build…), BarColumn(30) + TimeElapsedColumn, bold summary line - Plain runner: same header line, passes compose_cmd through - Summary uses bold green/yellow/red instead of plain colours - All update functions accept compose_cmd parameter docker-update-fzf (Bash): - project_status_by_name(): check running state via `docker ps --filter label=com.docker.compose.project=NAME` — works from any directory, no workdir required - Preview script rebuilt: shows container table + per-container logs via docker ps/logs --filter label=…, includes stopped containers fallback - show_action_menu() now uses project_status_by_name() for live dot / actions - Trap extended to INT/TERM for cleaner SSH exit https://claude.ai/code/session_01LtPxA1zDET2JQn6NYDDxKn --- docker-update | 184 ++++++++++++++++++++++++++++++++++------------ docker-update-fzf | 70 +++++++++++------- 2 files changed, 182 insertions(+), 72 deletions(-) diff --git a/docker-update b/docker-update index e05d3dc..76bc5c9 100755 --- a/docker-update +++ b/docker-update @@ -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 diff --git a/docker-update-fzf b/docker-update-fzf index d41b0e1..ccdc2aa 100755 --- a/docker-update-fzf +++ b/docker-update-fzf @@ -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) +name=$(printf '%s' "$line" | cut -f2) -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"