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
This commit is contained in:
Executable
+435
@@ -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 <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 "$@"
|
||||
Reference in New Issue
Block a user