diff --git a/docker/routlin-dash/app/action_actions.py b/docker/routlin-dash/app/action_actions.py index c460ba9..9e281a3 100644 --- a/docker/routlin-dash/app/action_actions.py +++ b/docker/routlin-dash/app/action_actions.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, redirect, flash, session from auth import require_level -from config_utils import (flush_pending_to_queue, flush_selected_to_queue, - delete_pending_by_uuids, get_dashboard_pending, +from config_utils import (flush_pending_to_queue, get_dashboard_pending, + revert_snapshot_to_core, _is_locked, _format_timing, _seconds_until_next_run) bp = Blueprint('action_actions', __name__) @@ -36,13 +36,22 @@ def actions_cardpending_applynow(): return redirect(_VIEW) -@bp.route('/action/actions_cardpending_revertselected', methods=['POST']) +@bp.route('/action/actions_cardhistory_revertselected', methods=['POST']) @require_level('administrator') -def actions_cardpending_revertselected(): +def actions_cardhistory_revertselected(): selected_uuids = request.form.getlist('selected_uuids') if not selected_uuids: flash('No items selected.', 'info') return redirect(_VIEW) - delete_pending_by_uuids(selected_uuids) - flash('Selected changes reverted.', 'success') + succeeded, failed = 0, 0 + for uuid in selected_uuids: + msg, ok = revert_snapshot_to_core(uuid) + if ok: + succeeded += 1 + else: + flash(msg, 'error') + failed += 1 + if succeeded: + plural = 's' if succeeded != 1 else '' + flash(f'{succeeded} change{plural} reverted.', 'success') return redirect(_VIEW) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 6ac55af..eeb7ed8 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -181,35 +181,6 @@ def flush_pending_to_queue(): _trim_if_needed() -def _remove_pending_by_uuids(uuid_set): - try: - lines = open(DASHBOARD_PENDING).read().splitlines() - except Exception: - return - kept = [l for l in lines if l.strip() and l.split(None, 1)[0] not in uuid_set] - with open(DASHBOARD_PENDING, 'w') as f: - f.write('\n'.join(kept) + ('\n' if kept else '')) - - -def flush_selected_to_queue(selected_uuids): - if not selected_uuids: - return - selected_set = set(selected_uuids) - items = _read_dashboard_pending() - done_set = _load_done_set() - existing_ids = {uu for uu, *_ in _read_pending(done_set)} - with open(DASHBOARD_QUEUE, 'a') as f: - for entry_uuid, entry_ts, entry_cmd, entry_user in items: - if entry_uuid in selected_set and entry_uuid not in existing_ids: - f.write(f'{entry_uuid} {entry_ts} [{entry_cmd}] ({entry_user})\n') - _remove_pending_by_uuids(selected_set) - _trim_if_needed() - - -def delete_pending_by_uuids(selected_uuids): - if not selected_uuids: - return - _remove_pending_by_uuids(set(selected_uuids)) def _queue_pending_command(cmd): diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 9027c10..8cba09a 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -442,7 +442,7 @@ def _blocklist_stats_html(cfg): if not rows: return '' return ( - '' + '
' '' '' '' @@ -593,55 +593,46 @@ def collect_tokens(): pending_items = get_dashboard_pending() if pending_items: + # Group by command; each group = one row in the Pending Actions table. + from collections import defaultdict + groups = defaultdict(list) + for _uuid, _ts, cmd, user in pending_items: + groups[cmd].append((_uuid, user)) rows = '' - _tr_onclick = ( - 'onclick="if(event.target.type!==\'checkbox\')' - 'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' - ) - for _uuid, ts, _cmd, user in pending_items: - snap = load_snapshot_for_uuid(_uuid) - dt_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M') - snap_desc = e(snap['description']) if snap else '' - before_val = snap.get('before') if snap else None - after_val = snap.get('after') if snap else None - snap_id = e(_uuid[:8]) if snap else '' - rows += (f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'{_snap_expand_row(before_val, after_val, 7)}') - select_all = ( - '' - ) + for cmd, entries in groups.items(): + users = ', '.join(sorted({u for _, u in entries})) + required_by_parts = [] + for _uuid, _ in entries: + snap = load_snapshot_for_uuid(_uuid) + required_by_parts.append(snap.get('description', _uuid[:8]) if snap else _uuid[:8]) + required_by = ', '.join(required_by_parts) + rows += (f'' + f'' + f'' + f'' + f'') pending_html = ( - '
BlocklistEntries
{e(dt_str)}{snap_desc}{_render_snap_val(before_val)}{_render_snap_val(after_val)}{snap_id}{e(user)}
{e(cmd)}{e(users)}{e(required_by)}
' + '
' '' - f'' - '' - '' - '' - '' - '' + '' '' + '' '' f'{rows}' '
{select_all}TimeDescriptionBeforeAfterSnapshotCommandUserRequired By
' ) else: - pending_html = '

No pending changes.

' - tokens['PENDING_CHANGES_HTML'] = pending_html + pending_html = '

No pending actions.

' + tokens['PENDING_ACTIONS_HTML'] = pending_html tokens['NO_PENDING'] = 'true' if not pending_items else '' done_items = get_dashboard_done() if done_items: hist_rows = '' - _hist_onclick = 'onclick="this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' + _hist_onclick = ( + 'onclick="if(event.target.type!==\'checkbox\')' + 'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' + ) for _uuid, applied_ts in done_items: snap = load_snapshot_for_uuid(_uuid) if applied_ts: @@ -653,7 +644,8 @@ def collect_tokens(): after_val = snap.get('after') if snap else None snap_id = e(_uuid[:8]) if snap else '' snap_user = e(snap['user']) if snap else '' - hist_rows += (f'' + hist_rows += (f'' + f'' f'{e(dt_str)}' f'{snap_desc}' f'{_render_snap_val(before_val)}' @@ -661,10 +653,15 @@ def collect_tokens(): f'{snap_id}' f'{snap_user}' f'' - f'{_snap_expand_row(before_val, after_val, 6)}') + f'{_snap_expand_row(before_val, after_val, 7)}') + select_all = ( + '' + ) history_html = ( - '' + '
' '' + f'' '' '' '' @@ -678,6 +675,7 @@ def collect_tokens(): else: history_html = '

No change history.

' tokens['CHANGE_HISTORY_HTML'] = history_html + tokens['NO_HISTORY'] = 'true' if not done_items else '' servers = dns.get('upstream_servers', []) tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false' @@ -843,19 +841,16 @@ def _render_snap_val(val): def _snap_expand_row(before_val, after_val, colspan): """Return a hidden that expands with full before/after content.""" - pre = ('max-height:200px;overflow-y:auto;white-space:pre-wrap;' - 'font-size:0.85em;background:#fff;border:1px solid #ddd;' - 'padding:0.5rem;margin:0.25rem 0') def box(label, val): text = _snap_text(val) if val is not None else '' if isinstance(val, (dict, list)): text = json.dumps(val, indent=2) - body = e(text) if text else '(none)' - return f'
{label}
{body}
' - inner = f'
{box("Before", before_val)}{box("After", after_val)}
' - return (f'' - f'') + body = e(text) if text else '(none)' + return (f'
' + f'{label}' + f'
{body}
') + inner = f'
{box("Before", before_val)}{box("After", after_val)}
' + return f'' def apply_tokens(text, tokens): @@ -944,7 +939,7 @@ def _render_item(item, tokens, inherited_req=None): formaction = e(apply_tokens(formaction, tokens)) return f'' if item.get('method', '').lower() == 'post': - return (f'' + return (f'' f'') return f'{text}' @@ -985,7 +980,7 @@ def _render_item(item, tokens, inherited_req=None): return ( f'
' f'
{label}
' - f'
' + f'
' f'{value}' f'' @@ -996,9 +991,9 @@ def _render_item(item, tokens, inherited_req=None): if edit_action and edit_field: min_attr = f' min="{e(edit_min)}"' if edit_min else '' suffix_html = f'{e(edit_suffix)}' if edit_suffix else '' - input_wrap = (f'
' + input_wrap = (f'
' f'' + f' data-original="{e(edit_raw)}" class="form-input stat-card-edit-input"{min_attr}/>' f'{suffix_html}
') return ( f'
' @@ -1007,10 +1002,10 @@ def _render_item(item, tokens, inherited_req=None): f'{value}' f'' f'
' - f'' + f'' f'' f'{input_wrap}' - f'
' + f'
' f'' f'' f'
' @@ -1027,10 +1022,10 @@ def _render_item(item, tokens, inherited_req=None): if t == 'card': label = item.get('label', '') id_attr = f' id="{e(item["id"])}"' if item.get('id') else '' - style = ' style="display:none"' if item.get('hidden') else '' + cls_hidden = ' hidden' if item.get('hidden') else '' header = f'

{e(label)}

' if label else '' body = render_items(item.get('items', []), tokens, req) - return f'
{header}
{body}
' + return f'
{header}
{body}
' if t == 'field_status': label = e(item.get('label', '')) @@ -1060,11 +1055,11 @@ def _render_item(item, tokens, inherited_req=None): psel = e(item.get('provider_select', 'provider')) return ( f'
' - f'
{select_all}AppliedDescriptionBefore