diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index e34c37d..e7571a7 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -357,7 +357,8 @@ def _db(): parent_path TEXT NOT NULL, item_key TEXT, item_value TEXT, - reverts_group TEXT + reverts_group TEXT, + reverted INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS changes ( group_id TEXT NOT NULL REFERENCES groups(uuid), @@ -510,8 +511,9 @@ def load_all_groups(): conn.close() -def revert_group(group_uuid): - """Revert a change group. Returns (flash_message, success_bool).""" +def revert_group(group_uuid, force=False): + """Revert a change group. Returns (flash_message, success_bool). + force=True skips the revert-of-revert guard, used by revert_group_chain.""" conn = _db() try: g = conn.execute('SELECT * FROM groups WHERE uuid=?', (group_uuid,)).fetchone() @@ -524,7 +526,7 @@ def revert_group(group_uuid): finally: conn.close() - if g['reverts_group']: + if g['reverts_group'] and not force: return 'Cannot revert a revert.', False cfg = load_config() @@ -556,9 +558,45 @@ def revert_group(group_uuid): inv = [(c['field'], c['after'], c['before'], c['value_type']) for c in changes] msg = record_group(cfg, parent_path, item_key, item_value, inv, g['cmd'], reverts_group=group_uuid) + conn = _db() + try: + conn.execute('UPDATE groups SET reverted=1 WHERE uuid=?', (group_uuid,)) + conn.commit() + finally: + conn.close() return msg, True +def revert_group_chain(group_uuid): + """Revert group_uuid and all subsequent groups touching the same item + (same parent_path + item_key + item_value), newest first. + Returns (error_messages, succeeded_count, failed_count).""" + conn = _db() + try: + g = conn.execute('SELECT * FROM groups WHERE uuid=?', (group_uuid,)).fetchone() + if not g: + return [f'Snapshot not found for {group_uuid[:8]}.'], 0, 1 + g = dict(g) + chain = [dict(r) for r in conn.execute( + 'SELECT * FROM groups ' + 'WHERE parent_path=? AND item_key IS ? AND item_value IS ? AND ts >= ? AND reverted=0 ' + 'ORDER BY ts DESC', + (g['parent_path'], g['item_key'], g['item_value'], g['ts']) + ).fetchall()] + finally: + conn.close() + + errors, succeeded, failed = [], 0, 0 + for grp in chain: + msg, ok = revert_group(grp['uuid'], force=True) + if ok: + succeeded += 1 + else: + errors.append(msg) + failed += 1 + return errors, succeeded, failed + + # Misc ============================================================== def run_apply(): diff --git a/docker/routlin-dash/app/pages/actions/action.py b/docker/routlin-dash/app/pages/actions/action.py index 085e0df..d06a3b5 100644 --- a/docker/routlin-dash/app/pages/actions/action.py +++ b/docker/routlin-dash/app/pages/actions/action.py @@ -2,7 +2,7 @@ from pathlib import Path from flask import Blueprint, request, redirect, flash, session from auth import require_level from config_utils import (flush_pending_to_queue, get_dashboard_pending, - revert_group, queued_msg, + revert_group, revert_group_chain, queued_msg, DASHBOARD_PENDING, _db) _PAGE = Path(__file__).parent.name @@ -37,17 +37,21 @@ def history_revert(): if not selected_uuids: flash('No items selected.', 'info') return redirect(f'/{_PAGE}') - succeeded, failed = 0, 0 - for uuid in selected_uuids: - msg, ok = revert_group(uuid) - if ok: - succeeded += 1 - else: - flash(msg, 'error') - failed += 1 + if len(selected_uuids) != 1: + flash('Please select exactly one change to revert.', 'error') + return redirect(f'/{_PAGE}') + + behavior = request.form.get('revert_behavior', 'revert_subsequent') + errors, succeeded, failed = revert_group_chain(selected_uuids[0]) + + for msg in errors: + flash(msg, 'error') if succeeded: - plural = 's' if succeeded != 1 else '' - flash(f'{succeeded} change{plural} reverted.', 'success') + s = 's' if succeeded != 1 else '' + if behavior == 'restore_state': + flash(f'Config restored to selected state ({succeeded} change{s} reverted).', 'success') + else: + flash(f'{succeeded} change{s} reverted.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/actions/content.json b/docker/routlin-dash/app/pages/actions/content.json index 95e6fe5..db6a628 100644 --- a/docker/routlin-dash/app/pages/actions/content.json +++ b/docker/routlin-dash/app/pages/actions/content.json @@ -110,7 +110,7 @@ "items": [ { "type": "raw_html", - "html": "" + "html": "" }, { "type": "button_secondary", diff --git a/docker/routlin-dash/app/pages/actions/view.py b/docker/routlin-dash/app/pages/actions/view.py index d17926e..d0d9b73 100644 --- a/docker/routlin-dash/app/pages/actions/view.py +++ b/docker/routlin-dash/app/pages/actions/view.py @@ -66,11 +66,7 @@ def collect_tokens(cfg): done_ts_map = get_done_timestamps() if all_groups: is_manager = client_level() >= LEVEL_RANK['manager'] - no_revert = set() - for g, _ in all_groups: - if g['reverts_group']: - no_revert.add(g['uuid']) - no_revert.add(g['reverts_group']) + no_revert = {g['uuid'] for g, _ in all_groups if g['reverted'] or g['reverts_group']} hist_rows = '' hist_onclick = ( 'onclick="if(event.target.type!==\'checkbox\')' @@ -91,7 +87,11 @@ def collect_tokens(cfg): else: verb = 'Edited' item = g.get('item_value') or '' - summary = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}' + summary_text = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}' + if g['reverted']: + summary = f'{e(summary_text)} Superseded' + else: + summary = e(summary_text) snap_tag = ( f'