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:
Claude
2026-04-11 10:34:27 +00:00
parent 970b44303a
commit 6c8206d2f6
3 changed files with 1149 additions and 0 deletions
+151
View File
@@ -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
```
Executable
+563
View File
@@ -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()
+435
View File
@@ -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 "$@"