61b472bbe8
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
456 lines
16 KiB
Bash
Executable File
456 lines
16 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 INT TERM
|
|
}
|
|
|
|
_cleanup() {
|
|
[[ -n "$_DU_TMPDIR" && -d "$_DU_TMPDIR" ]] && rm -rf "$_DU_TMPDIR"
|
|
}
|
|
|
|
die() {
|
|
printf 'docker-update-fzf: %s\n' "$*" >&2
|
|
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
|
|
# 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 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)
|
|
|
|
if [[ "$name" == "HEADER" || -z "$name" ]]; then
|
|
printf '\n (hover over a project to see its live container logs)\n'
|
|
exit 0
|
|
fi
|
|
|
|
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 '%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"
|
|
}
|
|
|
|
# ── 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"
|
|
|
|
local status dot
|
|
status=$(project_status_by_name "$name")
|
|
[[ "$status" == "running" ]] && dot="●" || dot="○"
|
|
|
|
local actions=""
|
|
[[ "$status" != "running" ]] && actions+=" ▶ Start\n"
|
|
actions+=" ↺ Update\n"
|
|
[[ "$status" == "running" ]] && 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 "$@"
|