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:
@@ -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
@@ -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()
|
||||||
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