diff --git a/README.md b/README.md new file mode 100644 index 0000000..e677aee --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# docker-update + +Docker Compose stack manager for **sammons-server** (Ubuntu Server 24.04). + +Discovers every Compose project on the host, updates it (git pull or registry +pull as appropriate), logs results, and provides an interactive fzf browser for +reviewing past runs and managing containers. + +--- + +## Commands + +| Command | What it does | +|---|---| +| `docker-update` | Update every Compose stack, save log | +| `docker-update log` | Browse past update logs in fzf | +| `docker-update debug` | Dry-run — shows what would happen, no changes | + +### docker-update + +Detects whether each project is tracked by a git repo or uses registry images, +then runs the appropriate update chain: + +| Type | Steps | +|---|---| +| git repo | `git pull` → `docker compose up -d --build` | +| registry | `docker compose down` → `docker compose pull` → `docker compose up -d` | + +Pull failures are **non-fatal** — the service is restarted with its existing +image and the run is marked `⚠ Failed to pull (restarted)`. +Stop failures **abort** the chain for that project. + +Rich progress bars are shown when `rich` is installed; plain-text output is +used as a fallback. + +Logs are saved to `/var/log/docker-update/docker-update-YYYY-MM-DD_HH-MM-SS.log` +with a companion `.json` sidecar used by the fzf browser. + +At the end of every run the summary line is printed and a reminder to use +`docker-update log` is shown. + +### docker-update log + +Opens a two-level fzf menu: + +1. **Log file picker** — lists past runs (newest first) with a preview of the + log file on the right. Press **Enter** to open a run. + +2. **Project browser** — shows every project from that run grouped by outcome, + with live status dots (`●` running / `○` stopped). Hover over any project + to see its live container logs in the preview pane. Press **Enter** to open + the action menu. + +3. **Action menu** — context-aware actions for the selected project: + + | Action | Shown when | + |---|---| + | `▶ Start` | container is stopped | + | `↺ Update` | always | + | `■ Stop` | container is running | + + After an action completes, press any key to return to the project browser + (status dots refresh automatically). + +Press **Esc** at any level to go back one level. + +`fzf` and `docker-update-fzf` must both be in `PATH`. If either is missing, +a plain-text fallback is used automatically after a warning. + +### docker-update debug + +Discovers all projects and prints what would happen without making any changes. +Optionally opens the fzf browser with a fake log (randomised outcomes) so you +can test the interface. All Start / Update / Stop actions in debug mode print +what they *would* run but do nothing. + +--- + +## Dependencies + +| Dependency | Notes | +|---|---| +| Python 3.8+ | must be at `/usr/bin/env python3` | +| `rich` | optional — plain-text fallback if missing | +| `fzf` | optional — plain-text fallback if missing | +| Docker Compose v2 plugin | `docker compose` (not `docker-compose`) | + +Install optional dependencies on Ubuntu: + +```bash +sudo apt install fzf +pip3 install rich +``` + +--- + +## Installation + +### 1 — Copy scripts to PATH + +```bash +sudo cp docker-update /usr/local/bin/docker-update +sudo cp docker-update-fzf /usr/local/bin/docker-update-fzf +``` + +### 2 — Make them executable + +```bash +sudo chmod +x /usr/local/bin/docker-update +sudo chmod +x /usr/local/bin/docker-update-fzf +``` + +### 3 — Create the log directory + +```bash +sudo mkdir -p /var/log/docker-update +sudo chmod 755 /var/log/docker-update +``` + +If you want a non-root user to run `docker-update` and write logs: + +```bash +sudo chown YOUR_USER:YOUR_USER /var/log/docker-update +``` + +### 4 — Verify + +```bash +docker-update debug +``` + +--- + +## File layout after installation + +``` +/usr/local/bin/docker-update ← Python main script +/usr/local/bin/docker-update-fzf ← Bash fzf helper (called by docker-update) +/var/log/docker-update/ ← Log directory (created automatically) + docker-update-YYYY-MM-DD_HH-MM-SS.log ← human-readable run log + docker-update-YYYY-MM-DD_HH-MM-SS.json ← machine-readable sidecar (for fzf) +``` + +--- + +## Uninstall + +```bash +sudo rm /usr/local/bin/docker-update /usr/local/bin/docker-update-fzf +sudo rm -rf /var/log/docker-update # optional — deletes all logs +``` diff --git a/docker-update b/docker-update new file mode 100755 index 0000000..e05d3dc --- /dev/null +++ b/docker-update @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +""" +docker-update — Docker Compose stack manager for sammons-server + +Usage: + docker-update Update all stacks + docker-update log Browse past update logs (fzf or plain-text fallback) + docker-update debug Dry-run: show what would happen, no changes made +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import textwrap +from datetime import datetime +from pathlib import Path +from typing import Any + +# ── optional rich ────────────────────────────────────────────────────────── +try: + from rich.console import Console + from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, + ) + from rich.table import Table + from rich.markup import escape as rich_escape + + HAS_RICH = True + console = Console() +except ImportError: + HAS_RICH = False + console = None # type: ignore[assignment] + +# ── constants ────────────────────────────────────────────────────────────── +LOG_DIR = Path("/var/log/docker-update") +FZF_HELPER = "docker-update-fzf" + +SUCCEEDED = "succeeded" +FAILED_PULL = "failed_pull" +FAILED_STOP = "failed_stop" +FAILED_RESTART = "failed_restart" + +OUTCOME_ORDER = [SUCCEEDED, FAILED_PULL, FAILED_STOP, FAILED_RESTART] + +OUTCOME_LABELS: dict[str, str] = { + SUCCEEDED: "Succeeded", + FAILED_PULL: "Failed to pull (restarted)", + FAILED_STOP: "Failed to stop", + FAILED_RESTART: "Failed to restart", +} + +OUTCOME_ICONS: dict[str, str] = { + SUCCEEDED: "✓", + FAILED_PULL: "⚠", + FAILED_STOP: "✗", + FAILED_RESTART: "✗", +} + +OUTCOME_COLORS: dict[str, str] = { + SUCCEEDED: "green", + FAILED_PULL: "yellow", + FAILED_STOP: "red", + FAILED_RESTART: "red", +} + + +# ── subprocess helper ────────────────────────────────────────────────────── + +def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, cwd=cwd, capture_output=True, text=True + ) + + +# ── project discovery ────────────────────────────────────────────────────── + +def discover_projects() -> list[dict[str, Any]]: + """Return sorted list of compose projects found on this host.""" + try: + r = _run(["docker", "compose", "ls", "--all", "--format", "json"]) + except FileNotFoundError: + sys.exit("error: 'docker' not found in PATH") + + if r.returncode != 0: + sys.exit(f"error: docker compose ls failed:\n{r.stderr.strip()}") + + try: + raw: list[dict[str, Any]] = json.loads(r.stdout or "[]") + except json.JSONDecodeError: + sys.exit(f"error: could not parse docker compose ls output:\n{r.stdout[:200]}") + + projects: list[dict[str, Any]] = [] + for item in raw: + name = item.get("Name", "").strip() + config = item.get("ConfigFiles", "").strip() + if not name or not config: + continue + workdir = str(Path(config.split(",")[0]).parent) + projects.append({ + "name": name, + "workdir": workdir, + "is_git": Path(workdir, ".git").is_dir(), + "status": item.get("Status", ""), + }) + + return sorted(projects, key=lambda p: p["name"].lower()) + + +# ── update logic ─────────────────────────────────────────────────────────── + +def _container_logs(workdir: str) -> str: + r = _run(["docker", "compose", "logs", "--tail=100", "--no-color"], cwd=workdir) + return (r.stdout or r.stderr or "(no logs available)").strip() + + +def update_project_git(project: dict[str, Any]) -> dict[str, Any]: + workdir = project["workdir"] + log_lines: list[str] = [] + + r = _run(["git", "pull"], cwd=workdir) + 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) + log_lines.append( + f"=== docker compose up -d --build (exit {r.returncode}) ===\n{r.stdout}{r.stderr}" + ) + + if r.returncode != 0: + log_lines.append( + f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}" + ) + outcome = FAILED_RESTART + elif not pull_ok: + outcome = FAILED_PULL + else: + outcome = SUCCEEDED + + return {**project, "outcome": outcome, "log_lines": log_lines} + + +def update_project_registry(project: dict[str, Any]) -> 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}") + 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}") + 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) + log_lines.append( + f"=== docker compose up -d (exit {r.returncode}) ===\n{r.stdout}{r.stderr}" + ) + + if r.returncode != 0: + log_lines.append( + f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}" + ) + outcome = FAILED_RESTART + elif not pull_ok: + outcome = FAILED_PULL + else: + outcome = SUCCEEDED + + return {**project, "outcome": outcome, "log_lines": log_lines} + + +# ── rich runner ──────────────────────────────────────────────────────────── + +def _run_update_rich(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + + with Progress( + SpinnerColumn(), + TextColumn("[bold]{task.description:<32}"), + BarColumn(), + TaskProgressColumn(), + transient=False, + console=console, + ) as prog: + task = prog.add_task("Updating stacks…", 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) + ) + 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'])}" + ) + + return results + + +# ── plain runner ─────────────────────────────────────────────────────────── + +def _run_update_plain(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + total = len(projects) + + for i, project in enumerate(projects, 1): + print(f"[{i}/{total}] Updating {project['name']}…", flush=True) + result = ( + update_project_git(project) + if project["is_git"] + else update_project_registry(project) + ) + results.append(result) + icon = OUTCOME_ICONS[result["outcome"]] + label = OUTCOME_LABELS[result["outcome"]] + print(f" {icon} {result['name']} ({label})") + + return results + + +# ── summary printers ─────────────────────────────────────────────────────── + +def _group_by_outcome( + results: list[dict[str, Any]], +) -> dict[str, list[dict[str, Any]]]: + groups: dict[str, list[dict[str, Any]]] = {k: [] for k in OUTCOME_ORDER} + for r in results: + groups[r["outcome"]].append(r) + return groups + + +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]") + if groups[FAILED_PULL]: + parts.append(f"[yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/yellow]") + if groups[FAILED_STOP]: + parts.append(f"[red]✗ {len(groups[FAILED_STOP])} failed to stop[/red]") + if groups[FAILED_RESTART]: + parts.append(f"[red]✗ {len(groups[FAILED_RESTART])} failed to restart[/red]") + console.print(" " + " ".join(parts)) + console.print() + + +def _print_summary_plain(groups: dict[str, list[dict[str, Any]]]) -> None: + print() + print("=" * 60) + for outcome in OUTCOME_ORDER: + items = groups[outcome] + if items: + icon = OUTCOME_ICONS[outcome] + label = OUTCOME_LABELS[outcome] + print(f"{icon} {label}: {len(items)}") + for r in items: + print(f" - {r['name']}") + print("=" * 60) + + +# ── log writers ──────────────────────────────────────────────────────────── + +def _write_text_log( + run_id: str, + results: list[dict[str, Any]], + path: Path, +) -> None: + groups = _group_by_outcome(results) + lines: list[str] = [ + f"docker-update run: {run_id}", + "=" * 60, + "", + ] + for outcome in OUTCOME_ORDER: + items = groups[outcome] + if items: + icon = OUTCOME_ICONS[outcome] + label = OUTCOME_LABELS[outcome] + lines.append(f"{icon} {label}: {len(items)}") + for r in items: + lines.append(f" - {r['name']}") + + failures = [r for r in results if r["outcome"] != SUCCEEDED] + if failures: + lines += ["", "=" * 60, "FAILURE DETAILS", "=" * 60] + for r in failures: + sep = "─" * 60 + lines += [ + "", + sep, + f" {r['name']} [{OUTCOME_LABELS[r['outcome']]}]", + sep, + "", + ] + for chunk in r.get("log_lines", []): + lines.append(chunk) + + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _write_json_log( + run_id: str, + results: list[dict[str, Any]], + path: Path, +) -> None: + data = { + "run_id": run_id, + "projects": [ + { + "name": r["name"], + "workdir": r["workdir"], + "is_git": r["is_git"], + "outcome": r["outcome"], + } + for r in results + ], + } + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +# ── cmd_update ───────────────────────────────────────────────────────────── + +def cmd_update() -> None: + LOG_DIR.mkdir(parents=True, exist_ok=True) + run_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_path = LOG_DIR / f"docker-update-{run_id}.log" + json_path = LOG_DIR / f"docker-update-{run_id}.json" + + projects = discover_projects() + if not projects: + print("No Docker Compose projects found.") + return + + results = _run_update_rich(projects) if HAS_RICH else _run_update_plain(projects) + + groups = _group_by_outcome(results) + _print_summary_rich(groups) if HAS_RICH else _print_summary_plain(groups) + + _write_text_log(run_id, results, log_path) + _write_json_log(run_id, results, json_path) + + print(f"Log saved to: {log_path}") + print() + print("To browse logs: docker-update log") + + +# ── cmd_log ──────────────────────────────────────────────────────────────── + +def cmd_log() -> None: + if not LOG_DIR.exists() or not any(LOG_DIR.glob("*.log")): + print(f"No log files found in {LOG_DIR}") + return + + fzf_bin = shutil.which("fzf") + helper_bin = shutil.which(FZF_HELPER) + + missing: list[str] = [] + if not fzf_bin: + missing.append("fzf") + if not helper_bin: + missing.append(FZF_HELPER) + + if missing: + print(f"Warning: {', '.join(missing)} not found — falling back to plain-text log viewer.") + print() + _cmd_log_plain() + return + + os.execvp(helper_bin, [helper_bin, "log", str(LOG_DIR)]) + + +def _cmd_log_plain() -> None: + logs = sorted(LOG_DIR.glob("*.log"), reverse=True) + if not logs: + print("No logs found.") + return + + print("Available logs (newest first):") + print() + for i, log in enumerate(logs, 1): + print(f" {i:3d}. {log.name}") + + print() + try: + raw = input("Enter number to view (or Enter to exit): ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + + if not raw: + return + + try: + selected = logs[int(raw) - 1] + print() + print("─" * 60) + print(selected.read_text(encoding="utf-8")) + except (ValueError, IndexError): + print("Invalid selection.") + + +# ── cmd_debug ────────────────────────────────────────────────────────────── + +def cmd_debug() -> None: + """Dry-run: discover projects and show what would happen, no changes.""" + projects = discover_projects() + if not projects: + print("No Docker Compose projects found.") + return + + if HAS_RICH: + _debug_rich(projects) + else: + _debug_plain(projects) + + print() + try: + answer = input("Show example log in fzf? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return + + if answer != "y": + return + + fzf_bin = shutil.which("fzf") + helper_bin = shutil.which(FZF_HELPER) + + missing = [] + if not fzf_bin: + missing.append("fzf") + if not helper_bin: + missing.append(FZF_HELPER) + + if missing: + print(f"Cannot open fzf demo: {', '.join(missing)} not found.") + return + + import random + import tempfile + + # Assign fake outcomes weighted towards success + outcome_pool = ( + [SUCCEEDED] * 6 + + [FAILED_PULL] * 2 + + [FAILED_STOP] + + [FAILED_RESTART] + ) + fake_projects = [ + { + "name": p["name"], + "workdir": p["workdir"], + "is_git": p["is_git"], + "outcome": random.choice(outcome_pool), + } + for p in projects + ] + + fake_data = { + "run_id": "debug-" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), + "debug": True, + "projects": fake_projects, + } + + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", prefix="du-debug-", delete=False + ) + json.dump(fake_data, tmp, indent=2) + tmp.flush() + tmp.close() + + os.execvp(helper_bin, [helper_bin, "debug", tmp.name]) + + +def _debug_rich(projects: list[dict[str, Any]]) -> None: + console.print() + console.rule("[bold cyan]Debug Mode — no changes will be made") + console.print() + + tbl = Table(show_header=True, header_style="bold") + tbl.add_column("Project", style="cyan", no_wrap=True) + tbl.add_column("Workdir", style="dim") + tbl.add_column("Type") + tbl.add_column("Update steps") + + for p in projects: + ptype = "[blue]git[/blue]" if p["is_git"] else "[magenta]registry[/magenta]" + steps = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d" + tbl.add_row(p["name"], p["workdir"], ptype, steps) + + console.print(tbl) + console.print() + console.print(f" [dim]{len(projects)} project(s) would be updated[/dim]") + + +def _debug_plain(projects: list[dict[str, Any]]) -> None: + print() + print("Debug Mode — no changes will be made") + print("=" * 60) + for p in projects: + ptype = "git" if p["is_git"] else "registry" + steps = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d" + print(f" {p['name']}") + print(f" workdir : {p['workdir']}") + print(f" type : {ptype}") + print(f" steps : {steps}") + print() + print(f"{len(projects)} project(s) would be updated") + + +# ── entrypoint ───────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + prog="docker-update", + description="Docker Compose stack manager", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + commands: + (none) update all stacks + log browse past update logs + debug dry-run — show what would run without making changes + """), + ) + parser.add_argument( + "command", + nargs="?", + choices=["log", "debug"], + default=None, + ) + args = parser.parse_args() + + if args.command == "log": + cmd_log() + elif args.command == "debug": + cmd_debug() + else: + cmd_update() + + +if __name__ == "__main__": + main() diff --git a/docker-update-fzf b/docker-update-fzf new file mode 100755 index 0000000..d41b0e1 --- /dev/null +++ b/docker-update-fzf @@ -0,0 +1,435 @@ +#!/bin/bash +# docker-update-fzf — fzf TUI helper for docker-update +# +# Called by docker-update for interactive log browsing and project management. +# Do not invoke directly unless you know what you're doing. +# +# Usage: +# docker-update-fzf log +# docker-update-fzf debug + +set -uo pipefail + +# ── global temp dir (cleaned up on exit) ─────────────────────────────────── +_DU_TMPDIR="" + +_setup_tmpdir() { + _DU_TMPDIR=$(mktemp -d /tmp/du-fzf-XXXXXX) + trap '_cleanup' EXIT +} + +_cleanup() { + [[ -n "$_DU_TMPDIR" && -d "$_DU_TMPDIR" ]] && rm -rf "$_DU_TMPDIR" +} + +die() { + printf 'docker-update-fzf: %s\n' "$*" >&2 + exit 1 +} + +# ── build_log_list ──────────────────────────────────────────────────────── +# Outputs tab-delimited lines: +# DISPLAY_TEXT \t FULL_LOG_PATH +# for every .log file in log_dir (newest first). +build_log_list() { + local log_dir="$1" + python3 - "$log_dir" << 'PYEOF' +import json, os, sys +from pathlib import Path + +log_dir = Path(sys.argv[1]) +logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True) + +for log in logs: + # Parse run_id from filename: docker-update-YYYY-MM-DD_HH-MM-SS.log + stem = log.stem # docker-update-YYYY-MM-DD_HH-MM-SS + ts_raw = stem.replace("docker-update-", "", 1) + # YYYY-MM-DD_HH-MM-SS -> "YYYY-MM-DD HH:MM:SS" + if "_" in ts_raw: + date_part, time_part = ts_raw.split("_", 1) + time_fmt = time_part.replace("-", ":") + ts_display = f"{date_part} {time_fmt}" + else: + ts_display = ts_raw + + # Count projects from JSON sidecar if available + json_path = log.with_suffix(".json") + count = "" + if json_path.exists(): + try: + data = json.loads(json_path.read_text()) + n = len(data.get("projects", [])) + count = f" ({n} projects)" + except Exception: + pass + + display = f" {ts_display}{count}" + print(f"{display}\t{log}") +PYEOF +} + +# ── build_project_list ──────────────────────────────────────────────────── +# Outputs tab-delimited lines: +# DISPLAY_TEXT \t NAME \t WORKDIR \t OUTCOME \t IS_GIT +# Section-header lines use NAME=HEADER and empty WORKDIR/OUTCOME/IS_GIT. +build_project_list() { + local json_file="$1" + python3 - "$json_file" << 'PYEOF' +import json, subprocess, sys + +json_file = sys.argv[1] +with open(json_file) as f: + data = json.load(f) + +# Get currently running projects in one fast call +running_names: set[str] = set() +try: + r = subprocess.run( + ["docker", "compose", "ls", "--format", "json"], + capture_output=True, text=True, timeout=6 + ) + for p in json.loads(r.stdout or "[]"): + if "running" in p.get("Status", "").lower(): + running_names.add(p.get("Name", "")) +except Exception: + pass + +OUTCOMES: dict[str, tuple[str, str, str]] = { + "succeeded": ("✓", "\033[0;32m", "Succeeded"), + "failed_pull": ("⚠", "\033[0;33m", "Failed to pull (restarted)"), + "failed_stop": ("✗", "\033[0;31m", "Failed to stop"), + "failed_restart": ("✗", "\033[0;31m", "Failed to restart"), +} +ORDER = ["succeeded", "failed_pull", "failed_stop", "failed_restart"] +RESET = "\033[0m" +DIM = "\033[2m" +BOLD = "\033[1m" + +# Group projects by outcome +groups: dict[str, list] = {o: [] for o in ORDER} +for p in data.get("projects", []): + outcome = p.get("outcome", "succeeded") + if outcome in groups: + groups[outcome].append(p) + +for outcome in ORDER: + items = groups[outcome] + if not items: + continue + + icon, color, label = OUTCOMES[outcome] + + # Section header (not selectable — bash loop detects NAME==HEADER) + bar = "─" * max(0, 44 - len(label)) + header = f" {DIM}── {label} {bar}{RESET}" + print(f"{header}\tHEADER\t\t\t") + + for p in items: + name = p["name"] + workdir = p.get("workdir", "") + is_git = "true" if p.get("is_git", False) else "false" + dot = "●" if name in running_names else "○" + dot_c = f"{DIM}{dot}{RESET}" + name_c = f"{color}{name}{RESET}" + icon_c = f"{color}{icon}{RESET}" + display = f" {icon_c} {dot_c} {name_c}" + print(f"{display}\t{name}\t{workdir}\t{outcome}\t{is_git}") +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. +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 + 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 +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' +PREVIEW_EOF + chmod +x "$script" + echo "$script" +} + +# ── write_log_preview_script ────────────────────────────────────────────── +# Receives tab-delimited log-list line ($1) and cats the log file. +write_log_preview_script() { + local script="$_DU_TMPDIR/log_preview.sh" + cat > "$script" << 'LOGPREVIEW_EOF' +#!/bin/bash +path=$(printf '%s' "$1" | cut -f2) +if [[ -z "$path" || ! -f "$path" ]]; then + printf '(no log file found)\n' + exit 1 +fi +cat "$path" +LOGPREVIEW_EOF + chmod +x "$script" + echo "$script" +} + +# ── show_action_menu ────────────────────────────────────────────────────── +# Args: name workdir is_git is_debug +# Echoes the chosen action line; returns 1 on Esc. +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 actions="" + [[ "$running_count" -eq 0 ]] && actions+=" ▶ Start\n" + actions+=" ↺ Update\n" + [[ "$running_count" -gt 0 ]] && actions+=" ■ Stop\n" + + local debug_note="" + [[ "$is_debug" == "true" ]] && debug_note=" \033[33m[DEBUG — no real changes]\033[0m" + + local selected + selected=$(printf "%b" "$actions" | \ + fzf \ + --reverse \ + --no-info \ + --ansi \ + --prompt=" Action > " \ + --header="$(printf " %s %s%s" "$dot" "$name" "$debug_note")" \ + --bind="esc:abort" \ + < /dev/tty) || return 1 + + echo "$selected" +} + +# ── execute_action ──────────────────────────────────────────────────────── +# Args: action_line name workdir is_git is_debug +execute_action() { + local action_line="$1" name="$2" workdir="$3" is_git="$4" is_debug="$5" + + # Extract verb from display line " ▶ Start" → "Start" + local verb + verb=$(echo "$action_line" | awk '{print $NF}') + + printf '\n\033[2m%s\033[0m\n' "$(printf '─%.0s' {1..60})" + + if [[ "$is_debug" == "true" ]]; then + printf ' \033[33m[DEBUG]\033[0m Would run: \033[1m%s\033[0m on \033[1m%s\033[0m\n\n' \ + "$verb" "$name" + case "$verb" in + Start) + printf ' \033[2mcd %s && docker compose up -d\033[0m\n' "$workdir" + ;; + Stop) + printf ' \033[2mcd %s && docker compose down\033[0m\n' "$workdir" + ;; + Update) + if [[ "$is_git" == "true" ]]; then + printf ' \033[2mcd %s && git pull && docker compose up -d --build\033[0m\n' "$workdir" + else + printf ' \033[2mcd %s && docker compose down && docker compose pull && docker compose up -d\033[0m\n' "$workdir" + fi + ;; + esac + else + printf ' \033[1m%s\033[0m → \033[1m%s\033[0m\n\n' "$name" "$verb" + case "$verb" in + Start) + (cd "$workdir" && docker compose up -d 2>&1) || true + ;; + Stop) + (cd "$workdir" && docker compose down 2>&1) || true + ;; + Update) + if [[ "$is_git" == "true" ]]; then + printf ' \033[2m→ git pull\033[0m\n' + (cd "$workdir" && git pull 2>&1) || true + printf '\n \033[2m→ docker compose up -d --build\033[0m\n' + (cd "$workdir" && docker compose up -d --build 2>&1) || true + else + printf ' \033[2m→ docker compose down\033[0m\n' + (cd "$workdir" && docker compose down 2>&1) || true + printf '\n \033[2m→ docker compose pull\033[0m\n' + (cd "$workdir" && docker compose pull 2>&1) || true + printf '\n \033[2m→ docker compose up -d\033[0m\n' + (cd "$workdir" && docker compose up -d 2>&1) || true + fi + ;; + esac + fi + + printf '\n\033[2m%s\033[0m\n' "$(printf '─%.0s' {1..60})" + printf ' Press any key to return…' + read -r -s -n1 < /dev/tty + printf '\n' +} + +# ── show_project_list ───────────────────────────────────────────────────── +# Args: json_file is_debug +# Runs the project selection + action loop. Returns when user presses Esc. +show_project_list() { + local json_file="$1" is_debug="$2" + local preview_script + preview_script=$(write_preview_script) + + while true; do + local list + list=$(build_project_list "$json_file") || { + printf 'Failed to build project list\n' >&2 + return 1 + } + + if [[ -z "$list" ]]; then + printf '\n No projects found in log.\n' + printf ' Press any key…' + read -r -s -n1 < /dev/tty + return 0 + fi + + local selected + selected=$(printf '%s\n' "$list" | \ + fzf \ + --reverse \ + --ansi \ + --no-info \ + --delimiter=$'\t' \ + --with-nth=1 \ + --prompt=" Project > " \ + --header=$' \033[2mEnter: manage Esc: back\033[0m' \ + --preview="$preview_script {}" \ + --preview-window="right:50%:wrap" \ + --bind="esc:abort" \ + < /dev/tty) || return 0 # Esc → back + + local item_type + item_type=$(printf '%s' "$selected" | cut -f2) + # Skip section-header lines + [[ "$item_type" == "HEADER" ]] && continue + + local name workdir outcome is_git + name=$(printf '%s' "$selected" | cut -f2) + workdir=$(printf '%s' "$selected" | cut -f3) + outcome=$(printf '%s' "$selected" | cut -f4) + is_git=$(printf '%s' "$selected" | cut -f5) + + [[ -z "$name" || -z "$workdir" ]] && continue + + local action + action=$(show_action_menu "$name" "$workdir" "$is_git" "$is_debug") || continue + + execute_action "$action" "$name" "$workdir" "$is_git" "$is_debug" + # Loop: refresh list and show again + done +} + +# ── tui_log ─────────────────────────────────────────────────────────────── +tui_log() { + local log_dir="${1:-/var/log/docker-update}" + + [[ -d "$log_dir" ]] || die "Log directory not found: $log_dir" + + local log_preview_script + log_preview_script=$(write_log_preview_script) + + while true; do + local log_list + log_list=$(build_log_list "$log_dir") || break + + if [[ -z "$log_list" ]]; then + printf '\n No log files found in %s\n' "$log_dir" + break + fi + + local selected_log_line + selected_log_line=$(printf '%s\n' "$log_list" | \ + fzf \ + --reverse \ + --ansi \ + --no-info \ + --delimiter=$'\t' \ + --with-nth=1 \ + --prompt=" Log file > " \ + --header=$' \033[2mEnter: browse projects Esc: exit\033[0m' \ + --preview="$log_preview_script {}" \ + --preview-window="right:60%:wrap" \ + --bind="esc:abort" \ + < /dev/tty) || break # Esc → exit + + local log_path + log_path=$(printf '%s' "$selected_log_line" | cut -f2) + local json_path="${log_path%.log}.json" + + if [[ ! -f "$json_path" ]]; then + printf '\n \033[33mNote:\033[0m No JSON sidecar found for %s\n' \ + "$(basename "$log_path")" + printf ' (Log was created before JSON sidecars were supported.)\n' + printf ' Press any key…' + read -r -s -n1 < /dev/tty + printf '\n' + continue + fi + + # Level 2+: browse projects from this log + show_project_list "$json_path" "false" + done + + printf '\n' +} + +# ── tui_debug ───────────────────────────────────────────────────────────── +tui_debug() { + local json_file="$1" + [[ -f "$json_file" ]] || die "JSON file not found: $json_file" + + printf '\n' + printf ' \033[33m┌──────────────────────────────────────────────────┐\033[0m\n' + printf ' \033[33m│ DEBUG MODE — no real actions will be performed │\033[0m\n' + printf ' \033[33m└──────────────────────────────────────────────────┘\033[0m\n' + printf '\n' + sleep 1 + + show_project_list "$json_file" "true" + + # Clean up temp JSON written by docker-update debug + rm -f "$json_file" +} + +# ── main ────────────────────────────────────────────────────────────────── +main() { + local mode="${1:-}" + shift || true + + _setup_tmpdir + + case "$mode" in + log) tui_log "$@" ;; + debug) tui_debug "$@" ;; + *) + printf 'Usage: docker-update-fzf {log|debug} [args]\n' >&2 + exit 1 + ;; + esac +} + +main "$@"