# ShopDB backup/restore status + history helper. # # WHY this exists: the restore and backup runs happen in the watcher container, # but the operator watches the UI, which only talks to the api. This helper is # the bridge: it writes two kinds of state onto the shared /backups volume that # the api can read. # # 1. A LIVE status file (.restore-status.json / .backup-status.json) rewritten # at each phase so the UI can show an indeterminate spinner with a phase # label (queued, restoring files, loading database, done, failed). # 2. An append-only HISTORY file (history/restores.json / history/backups.json) # that keeps the record of past runs so the UI can list them in a card. # # Both writes are atomic (write temp, rename) so the api never reads a half # written file. History is bounded to the most recent HISTORY_MAX entries. # # This file is SOURCED, not executed. It exposes: # status_write [message] # history_append [message] # # kind is "restore" or "backup". ASCII only, no emojis - matches the style gate. STATUS_BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}" HISTORY_MAX="${BACKUP_HISTORY_MAX:-50}" _status_now() { date -u +%Y-%m-%dT%H%M%SZ; } _status_json_escape() { # Escape backslashes and double quotes for embedding in a JSON string. printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g' } # status_write [message] # Rewrites the live status file for this run kind. status_write() { kind="$1" phase="$2" setname="${3:-}" message="${4:-}" statusfile="${STATUS_BACKUPS_ROOT}/.${kind}-status.json" tmpfile="${statusfile}.tmp.$$" { printf '{' printf '"kind":"%s",' "$(_status_json_escape "${kind}")" printf '"phase":"%s",' "$(_status_json_escape "${phase}")" printf '"setname":"%s",' "$(_status_json_escape "${setname}")" printf '"message":"%s",' "$(_status_json_escape "${message}")" printf '"updatedat":"%s"' "$(_status_now)" printf '}\n' } > "${tmpfile}" 2>/dev/null || return 0 mv -f "${tmpfile}" "${statusfile}" 2>/dev/null || rm -f "${tmpfile}" 2>/dev/null return 0 } # history_append [message] # Appends a completed run record and trims to HISTORY_MAX newest entries. history_append() { kind="$1" outcome="$2" setname="${3:-}" message="${4:-}" historydir="${STATUS_BACKUPS_ROOT}/history" historyfile="${historydir}/${kind}s.json" tmpfile="${historyfile}.tmp.$$" mkdir -p "${historydir}" 2>/dev/null || return 0 record="$(printf '{"setname":"%s","outcome":"%s","message":"%s","finishedat":"%s"}' \ "$(_status_json_escape "${setname}")" \ "$(_status_json_escape "${outcome}")" \ "$(_status_json_escape "${message}")" \ "$(_status_now)")" if command -v python3 >/dev/null 2>&1; then HISTORY_FILE="${historyfile}" HISTORY_RECORD="${record}" \ HISTORY_MAX="${HISTORY_MAX}" python3 - "${tmpfile}" <<'PY' 2>/dev/null || return 0 import json, os, sys path = os.environ["HISTORY_FILE"] record = json.loads(os.environ["HISTORY_RECORD"]) limit = int(os.environ.get("HISTORY_MAX", "50")) tmp = sys.argv[1] try: with open(path, "r", encoding="utf-8") as handle: entries = json.load(handle) if not isinstance(entries, list): entries = [] except (OSError, ValueError): entries = [] entries.append(record) entries = entries[-limit:] with open(tmp, "w", encoding="utf-8") as handle: json.dump(entries, handle) os.replace(tmp, path) PY fi return 0 }