#!/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 "$@"