it is risen - add restore progress and backup/restore history

The watcher scripts now write live status and append-only run history onto the shared volume; the api exposes them via /shopdbbackups/status and /history. The restore modal shows an indeterminate spinner with phase labels, and two cards list backup and restore run records. Timestamps render in UTC to match the Zulu-stamped set ids, the history directory is excluded from the set list, and a stale-status guard stops a previous run's done phase from collapsing the spinner on the next restore.
This commit is contained in:
benjamin.baker
2026-09-03 20:43:42 -04:00
parent 7e4469c44e
commit debe1fce69
6 changed files with 485 additions and 3 deletions

View File

@@ -185,6 +185,38 @@
</tbody>
</table>
<!-- Operational run records, written by the backup container onto the
shared volume. Two cards: what was backed up, and what was restored. -->
<div class="history-cards">
<div class="history-card">
<h3 class="history-title">Backup history</h3>
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
<div class="history-card">
<h3 class="history-title">Restore history</h3>
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
</div>
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
UI budget on the compatibility verdict before allowing the click. -->
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
@@ -192,6 +224,34 @@
<h3>Restore this backup?</h3>
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
<!-- Progress mode: once a restore is queued we swap the compatibility
body for an indeterminate spinner and the current phase label. -->
<template v-if="restorePhase">
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
<component
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
v-else
:size="18"
/>
<span class="restore-phase">{{ restorePhaseLabel }}</span>
</div>
<p class="restore-hint">
Keep this open until the restore finishes. This can take a moment.
</p>
<div class="modal-actions">
<button
class="btn-ghost"
:disabled="isRestoreRunning"
@click="closeRestore"
>
Close
</button>
</div>
</template>
<template v-else>
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
<span v-if="restoreTarget.iscompatible">
@@ -224,13 +284,14 @@
Restore
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
import api from '../../api'
import { useToast } from '../../composables/toast'
@@ -243,6 +304,37 @@ const isBusy = ref(false)
const lastGoodAt = ref(null)
const restoreTarget = ref(null)
// Live restore progress. restoreStatus mirrors the api /status.restore payload;
// restorePhase drives the indeterminate spinner and label in the modal.
const restoreStatus = ref(null)
let statusTimer = null
let restoreStartedAt = 0
const backups = ref([])
const restores = ref([])
// Human labels for the phase names the watcher scripts write. Anything we do
// not recognize falls back to the raw phase so a new phase still shows.
const RESTORE_PHASE_LABELS = {
queued: 'Queued...',
checking: 'Checking compatibility...',
files: 'Restoring files...',
database: 'Loading database...',
done: 'Restore complete',
failed: 'Restore failed',
}
const restorePhase = computed(() => restoreStatus.value?.phase || null)
const isRestoreRunning = computed(() => {
const phase = restorePhase.value
return !!phase && phase !== 'done' && phase !== 'failed'
})
const restorePhaseLabel = computed(() => {
const phase = restorePhase.value
if (!phase) return ''
return RESTORE_PHASE_LABELS[phase] || phase
})
const settingsOpen = ref(false)
const settingsLoading = ref(true)
const savingSettings = ref(false)
@@ -332,6 +424,8 @@ function openRestore(set) {
}
function closeRestore() {
stopStatusPolling()
restoreStatus.value = null
restoreTarget.value = null
}
@@ -341,13 +435,73 @@ async function confirmRestore() {
try {
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
toast.success('Restore queued and validated against the live schema.')
closeRestore()
// Keep the modal open and switch it to progress mode; polling drives the
// spinner through each phase until the watcher reports done or failed.
startStatusPolling()
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not queue the restore.')
} finally {
isBusy.value = false
}
}
async function loadHistory() {
try {
const response = await api.get('/shopdbbackups/history')
const data = response.data.data || {}
backups.value = data.backups || []
restores.value = data.restores || []
} catch (error) {
// History is non-critical; a failure here should not disrupt the page.
}
}
async function pollStatus() {
let status
try {
const response = await api.get('/shopdbbackups/status')
status = response.data.data?.restore || null
} catch (error) {
return
}
// Ignore a terminal status left over from a previous restore: if it finished
// before we started this one, it is stale and would close the modal instantly
// before the spinner ever paints. Wait for a fresh status from this run.
if (status && (status.phase === 'done' || status.phase === 'failed')) {
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
return
}
}
restoreStatus.value = status
const phase = status?.phase
if (phase === 'done' || phase === 'failed') {
stopStatusPolling()
if (phase === 'done') {
toast.success('Restore complete.')
closeRestore()
} else {
toast.error(status?.message || 'Restore failed.')
}
load()
loadHistory()
}
}
function startStatusPolling() {
stopStatusPolling()
restoreStartedAt = Date.now()
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
// Poll on an interval only; skip the immediate call so the seeded "queued"
// phase is guaranteed to paint before the first real status can replace it.
statusTimer = setInterval(pollStatus, 1000)
}
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
@@ -385,7 +539,17 @@ function formatTaken(value) {
if (!value) return 'unknown'
const date = new Date(normalizeStamp(value))
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
// Stamps are UTC; render them in UTC so the displayed time matches the
// Zulu-stamped set id rather than shifting to the operator's local zone.
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
}
function outcomeClass(outcome) {
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
}
function outcomeLabel(outcome) {
return outcome === 'success' ? 'Success' : 'Failed'
}
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
@@ -425,6 +589,11 @@ function statusLabel(set) {
onMounted(() => {
load()
loadSettings()
loadHistory()
})
onUnmounted(() => {
stopStatusPolling()
})
</script>
@@ -570,6 +739,85 @@ onMounted(() => {
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
.restore-progress {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
background: rgba(56, 161, 105, 0.15);
color: #9ae6b4;
font-size: 0.9rem;
}
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.restore-phase { font-weight: 600; }
.restore-hint {
color: var(--text-light);
font-size: 0.8rem;
margin: 0.5rem 0 0 0;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(154, 230, 180, 0.35);
border-top-color: #9ae6b4;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.history-cards {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.history-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 1rem 1.1rem;
}
.history-title {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
}
.history-row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 0;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.history-row:first-child { border-top: none; }
.history-outcome {
flex-shrink: 0;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.history-set {
flex: 1;
font-family: monospace;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-when { color: var(--text-light); flex-shrink: 0; }
.modal-actions {
display: flex;
justify-content: flex-end;

View File

@@ -23,9 +23,28 @@ STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
SETNAME="${SITE}-${STAMP}"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
log() { echo "[backup] $*"; }
# Record the run outcome on any exit so a failed backup is visible in the UI
# history, not just a silent gap.
BACKUP_DONE=false
_on_exit() {
code=$?
if [ "${BACKUP_DONE}" = true ]; then
status_write backup done "${SETNAME}" "Backup complete"
history_append backup success "${SETNAME}" "Backup complete"
else
status_write backup failed "${SETNAME}" "Backup failed (exit ${code})"
history_append backup failed "${SETNAME}" "Backup failed (exit ${code})"
fi
}
trap _on_exit EXIT
mkdir -p "${SETDIR}"
status_write backup database "${SETNAME}" "Dumping database"
# 1. Database: whole-DB logical dump, consistent and non-blocking on InnoDB.
log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}"
@@ -53,6 +72,7 @@ python3 /opt/backup/manifest.py \
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
# Skips gracefully when restic/repo are not configured (local-only sites).
IS_CHECK_PASSED=false
status_write backup snapshot "${SETNAME}" "Snapshotting to backup store"
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
if ! restic snapshots >/dev/null 2>&1; then
log "initializing restic repository at ${RESTIC_REPOSITORY}"
@@ -95,4 +115,5 @@ python3 /opt/backup/manifest.py \
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
BACKUP_DONE=true
log "backup set ${SETNAME} complete"

View File

@@ -21,11 +21,32 @@ set -euo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SETNAME="${1:-}"
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
log() { echo "[restore] $*"; }
# On any exit, stamp the terminal phase and append a history record so the UI
# reflects success or failure even when a step aborts early (set -e).
RESTORE_DONE=false
_on_exit() {
code=$?
if [ "${RESTORE_DONE}" = true ]; then
status_write restore done "${SETNAME}" "Restore complete"
history_append restore success "${SETNAME}" "Restore complete"
else
status_write restore failed "${SETNAME}" "Restore failed (exit ${code})"
history_append restore failed "${SETNAME}" "Restore failed (exit ${code})"
fi
}
trap _on_exit EXIT
die() { echo "[restore] ERROR: $*" >&2; exit 1; }
[ -n "${SETNAME}" ] || die "usage: restore.sh <snapshot-set-name>"
status_write restore checking "${SETNAME}" "Checking schema compatibility"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
DBDUMP="${SETDIR}/db.sql.gz"
@@ -60,11 +81,13 @@ fi
# Files first, then DB, so the two land together.
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
status_write restore files "${SETNAME}" "Restoring instance files"
log "restoring instance files from restic"
restic restore latest --target / --include /app/instance \
|| die "restic restore of instance files failed"
fi
status_write restore database "${SETNAME}" "Loading database dump"
log "loading database dump"
gunzip -c "${DBDUMP}" | mysql \
--host="${MYSQL_HOST}" \
@@ -73,4 +96,5 @@ gunzip -c "${DBDUMP}" | mysql \
|| die "database load failed"
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
RESTORE_DONE=true
log "restore of ${SETNAME} complete"

100
scripts/backup/status.sh Normal file
View File

@@ -0,0 +1,100 @@
# 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 <kind> <phase> <setname> [message]
# history_append <kind> <outcome> <setname> [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 <kind> <phase> <setname> [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 <kind> <outcome> <setname> [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
}

View File

@@ -19,6 +19,9 @@ INTERVAL="${BACKUP_POLL_SECONDS:-10}"
log() { echo "[backup-watch] $*"; }
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
mkdir -p "${BACKUPS_ROOT}"
# The api container runs unprivileged and only drops sentinel files here; it
# cannot write into a root-owned 0755 directory. Make the backups root
@@ -29,6 +32,7 @@ log "watcher up; polling ${SENTINEL} and ${RESTORE_SENTINEL} every ${INTERVAL}s"
while true; do
if [ -f "${SENTINEL}" ]; then
log "backup requested - running one-shot"
status_write backup queued "" "Backup queued"
if /opt/backup/backup.sh; then
log "backup completed"
else
@@ -45,6 +49,7 @@ while true; do
# fails so a bad set cannot pin the watcher in a tight retry loop.
setname="$(head -n1 "${RESTORE_SENTINEL}" 2>/dev/null | tr -d '\r\n')"
log "restore requested - running restore for ${setname}"
status_write restore queued "${setname}" "Restore queued"
if /opt/backup/restore.sh "${setname}"; then
log "restore completed"
else

View File

@@ -85,6 +85,51 @@ def _backups_root():
return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT)
def _read_json(path):
"""Load a JSON file, or None when it is absent or unreadable."""
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _read_status(kind):
"""Return the live status dict for a run kind, or None when idle."""
root = _backups_root()
return _read_json(os.path.join(root, '.' + kind + '-status.json'))
def _read_history(kind):
"""Return the recorded run history for a kind, newest first."""
root = _backups_root()
entries = _read_json(os.path.join(root, 'history', kind + 's.json'))
if not isinstance(entries, list):
return []
return list(reversed(entries))
def _write_status(kind, phase, setname, message):
"""Seed a live status file so the UI shows movement the instant a run is
queued, before the watcher has picked up the sentinel."""
root = _backups_root()
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
payload = {
'kind': kind,
'phase': phase,
'setname': setname,
'message': message,
'updatedat': stamp,
}
try:
path = os.path.join(root, '.' + kind + '-status.json')
with open(path, 'w', encoding='utf-8') as handle:
json.dump(payload, handle)
except OSError:
pass
def _read_manifest(setdir):
"""Load a set's manifest.json, or None when absent/unreadable.
@@ -146,6 +191,11 @@ def _is_compatible(manifest, current):
return setrevs.issubset(currevs)
# Reserved entries under the backups root that are not backup sets: the run
# history store and any dotfile sentinels/status files the watcher drops.
_NON_SET_ENTRIES = ('history',)
def _list_sets():
"""Enumerate backup sets newest-first, each summarized for the list view."""
root = _backups_root()
@@ -154,6 +204,8 @@ def _list_sets():
current = _current_schema_revisions()
sets = []
for name in sorted(os.listdir(root), reverse=True):
if name in _NON_SET_ENTRIES or name.startswith('.'):
continue
setdir = os.path.join(root, name)
if not os.path.isdir(setdir):
continue
@@ -235,6 +287,7 @@ def trigger_backup():
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue a backup: ' + str(error),
http_code=500)
_write_status('backup', 'queued', '', 'Backup queued')
AuditLog.log('requested', 'Backup', entityname=stamp)
return success_response(
data={'isrunning': False, 'isqueued': True, 'requestedat': stamp},
@@ -276,6 +329,8 @@ def restore_backup(snapshotid):
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue the restore: ' + str(error),
http_code=500)
_write_status('restore', 'queued', os.path.basename(snapshotid),
'Restore queued')
AuditLog.log('restore-requested', 'Backup',
entityname=os.path.basename(snapshotid))
return success_response(
@@ -283,6 +338,35 @@ def restore_backup(snapshotid):
message='Restore queued and validated against the live schema.')
@shopdbbackups_bp.route('/status', methods=['GET'])
@require_role('admin')
def get_status():
"""Return the live status of the most recent backup and restore runs.
The watcher writes these files as it moves through each phase; the UI polls
this endpoint to drive the restore progress spinner and phase label. Either
value is None when that kind of run has never been requested.
"""
return success_response(data={
'backup': _read_status('backup'),
'restore': _read_status('restore'),
})
@shopdbbackups_bp.route('/history', methods=['GET'])
@require_role('admin')
def get_history():
"""Return the recorded history of backup and restore runs, newest first.
These are operational run logs written by the backup container onto the
shared volume, not the audit trail (AuditLog records the requested intent).
"""
return success_response(data={
'backups': _read_history('backup'),
'restores': _read_history('restore'),
})
@shopdbbackups_bp.route('/settings', methods=['GET'])
@require_role('admin')
def get_backup_settings():