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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user