Files
Docker-Update/docker-update-fzf
T
Claude 6c8206d2f6 Initial implementation: docker-update + docker-update-fzf
Rebuilt as a hybrid Python + Bash project to fix TTY/fzf conflicts over SSH.

Python (docker-update):
- `docker-update`        — update all stacks with Rich progress (plain fallback)
- `docker-update log`    — exec into fzf helper, or plain-text fallback with warning
- `docker-update debug`  — dry-run discovery + optional fzf demo with fake data
- Writes .log + .json sidecar per run to /var/log/docker-update/
- Summary line + "docker-update log" hint printed after every update run

Bash (docker-update-fzf):
- Level 1: log-file picker (fzf, newest first, preview shows log content)
- Level 2: project browser grouped by outcome (✓ ⚠ ✗) with live ●/○ status dots
           and live docker compose logs in preview pane
- Level 3: context-aware action menu (Start / Update / Stop)
- Proper < /dev/tty TTY handling throughout — no freezes over SSH
- Debug mode — all actions print what they would run but execute nothing
- Rich and fzf both have plain-text fallbacks with upfront user warning
- Temp files cleaned up via EXIT trap

README.md explains dependencies, installation paths, and usage.

https://claude.ai/code/session_01LtPxA1zDET2JQn6NYDDxKn
2026-04-11 10:38:02 +00:00

436 lines
15 KiB
Bash
Executable File

#!/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 <log_dir>
# docker-update-fzf debug <json_file>
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 "$@"