diff --git a/docker/routlin-dash/app/action_ddns.py b/docker/routlin-dash/app/action_ddns.py index 8670f91..9f6e065 100644 --- a/docker/routlin-dash/app/action_ddns.py +++ b/docker/routlin-dash/app/action_ddns.py @@ -52,6 +52,7 @@ def ddns_cardaddaccount_add(): before=None, after=copy.deepcopy(entry), description=f'Added DDNS provider: {description}', cmd='ddns update', + queue=False, ), 'success') return redirect(VIEW) @@ -103,6 +104,7 @@ def ddns_tableaccounts_rowedit(): before=before, after=copy.deepcopy(entry), description=f'Edited DDNS provider: {description}', cmd='ddns update', + queue=False, ), 'success') return redirect(VIEW) @@ -134,6 +136,7 @@ def ddns_tableaccounts_rowdelete(): before=before, after=None, description=f'Deleted DDNS provider: {description}', cmd='ddns update', + queue=False, ), 'success') return redirect(VIEW) @@ -191,6 +194,7 @@ def ddns_cardipcheckservices_save(): before=before, after=copy.deepcopy(services), description='Updated DDNS IP check services', cmd='ddns update', + queue=False, ), 'success') return redirect(VIEW) @@ -219,6 +223,7 @@ def ddns_cardlogging_save(): before=before, after=copy.deepcopy(cfg['ddns']['general']), description='Updated DDNS logging settings', cmd='ddns update', + queue=False, ), 'success') return redirect(VIEW) diff --git a/docker/routlin-dash/app/action_dnsblocking.py b/docker/routlin-dash/app/action_dnsblocking.py index 4184604..e29ef81 100644 --- a/docker/routlin-dash/app/action_dnsblocking.py +++ b/docker/routlin-dash/app/action_dnsblocking.py @@ -1,7 +1,8 @@ +import copy import re from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config, verify_config_hash, queued_msg +from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg import sanitize import validation as validate @@ -62,21 +63,26 @@ def dnsblocking_tableblocklists_rowdelete(): if not _hash_ok(): return redirect(VIEW) - cfg = load_config() + cfg = load_config() items = cfg.get('dns_blocking', {}).get('blocklists', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(VIEW) + before = copy.deepcopy(items[idx]) + name = before.get('name', str(idx)) items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(VIEW) - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='dns_blocking', key=name, operation='delete', + before=before, after=None, + description=f'Deleted blocklist: {name}', + queue=False, + ), 'success') return redirect(VIEW) @@ -95,12 +101,13 @@ def dnsblocking_tableblocklists_rowedit(): if not _hash_ok(): return redirect(VIEW) - cfg = load_config() + cfg = load_config() items = cfg.get('dns_blocking', {}).get('blocklists', []) if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(VIEW) + before = copy.deepcopy(items[idx]) items[idx].update({ 'name': fields['name'], 'description': fields['description'], @@ -112,9 +119,12 @@ def dnsblocking_tableblocklists_rowedit(): for msg in errors: flash(msg, 'error') return redirect(VIEW) - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='dns_blocking', key=fields['name'], operation='edit', + before=before, after=copy.deepcopy(items[idx]), + description=f'Edited blocklist: {fields["name"]}', + queue=False, + ), 'success') return redirect(VIEW) @@ -128,28 +138,32 @@ def dnsblocking_cardaddblocklist_add(): if not _hash_ok(): return redirect(VIEW) - cfg = load_config() + cfg = load_config() blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', []) if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists): flash('The configuration has not been saved because a blocklist with that name already exists.', 'error') return redirect(VIEW) - blocklists.append({ + entry = { 'name': fields['name'], 'description': fields['description'], 'format': fields['format'], 'url': fields['url'], 'save_as': _save_as_from_name(fields['name']), - }) + } + blocklists.append(entry) errors = validate.validate_config(cfg) if errors: for msg in errors: flash(msg, 'error') return redirect(VIEW) - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='dns_blocking', key=fields['name'], operation='add', + before=None, after=copy.deepcopy(entry), + description=f'Added blocklist: {fields["name"]}', + queue=False, + ), 'success') return redirect(VIEW) @@ -166,11 +180,15 @@ def dnsblocking_cardblocklistrefresh_save(): flash('Configuration was modified by another session. Please refresh and try again.', 'error') return redirect(VIEW) - cfg = load_config() + cfg = load_config() + before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='dns_blocking', key='general', operation='edit', + before=before, after=copy.deepcopy(cfg['dns_blocking']['general']), + description='Updated daily blocklist refresh time', + cmd='core apply', + ), 'success') return redirect(VIEW) @@ -196,7 +214,8 @@ def dnsblocking_cardlogging_save(): flash('Configuration was modified by another session. Please refresh and try again.', 'error') return redirect(VIEW) - cfg = load_config() + cfg = load_config() + before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) cfg.setdefault('dns_blocking', {}).setdefault('general', {}).update({ 'log_max_kb': log_max_kb, 'log_errors_only': log_errors_only, @@ -206,7 +225,10 @@ def dnsblocking_cardlogging_save(): for msg in errors: flash(msg, 'error') return redirect(VIEW) - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='dns_blocking', key='general', operation='edit', + before=before, after=copy.deepcopy(cfg['dns_blocking']['general']), + description='Updated DNS blocking log settings', + queue=False, + ), 'success') return redirect(VIEW) diff --git a/docker/routlin-dash/app/action_networkinterfaces.py b/docker/routlin-dash/app/action_networkinterfaces.py index 0327cb9..6067337 100644 --- a/docker/routlin-dash/app/action_networkinterfaces.py +++ b/docker/routlin-dash/app/action_networkinterfaces.py @@ -1,8 +1,9 @@ +import copy import os from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config, verify_config_hash, queued_msg, queue_command +from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg, queue_command import sanitize import validation as validate @@ -54,8 +55,9 @@ def networkinterfaces_cardnetworkinterface_save(): flash(f"Interface '{iface}' does not exist on this system.", 'error') return redirect(_VIEW) - cfg = load_config() - gen = cfg.setdefault('network_interfaces', {}) + cfg = load_config() + before = copy.deepcopy(cfg.get('network_interfaces', {})) + gen = cfg.setdefault('network_interfaces', {}) gen['wan_interface'] = wan gen['lan_interface'] = lan errors = validate.validate_config(cfg) @@ -63,9 +65,12 @@ def networkinterfaces_cardnetworkinterface_save(): for msg in errors: flash(msg, 'error') return redirect(_VIEW) - save_config(cfg) - - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='network_interfaces', key='global', operation='edit', + before=before, after=copy.deepcopy(cfg['network_interfaces']), + description='Updated network interfaces', + cmd='core apply', + ), 'success') return redirect(_VIEW) diff --git a/docker/routlin-dash/app/action_upstreamdns.py b/docker/routlin-dash/app/action_upstreamdns.py index f1db200..09674c8 100644 --- a/docker/routlin-dash/app/action_upstreamdns.py +++ b/docker/routlin-dash/app/action_upstreamdns.py @@ -1,6 +1,7 @@ +import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config, verify_config_hash, queued_msg +from config_utils import load_config, save_config_with_snapshot, verify_config_hash import sanitize import validation as validate @@ -33,6 +34,7 @@ def upstreamdns_cardupstreamdns_save(): return redirect(_VIEW) cfg = load_config() + before = copy.deepcopy(cfg.get('upstream_dns', {})) current = cfg.get('upstream_dns', {}) if (strict_order == bool(current.get('strict_order', False)) and upstream_servers == current.get('upstream_servers', [])): @@ -48,8 +50,12 @@ def upstreamdns_cardupstreamdns_save(): for msg in errors: flash(msg, 'error') return redirect(_VIEW) - save_config(cfg) - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='upstream_dns', key='global', operation='edit', + before=before, after=copy.deepcopy(cfg['upstream_dns']), + description='Updated upstream DNS servers', + cmd='core apply', + ), 'success') return redirect(_VIEW) @@ -66,6 +72,7 @@ def upstreamdns_cardforwardingdnsservice_save(): return redirect(_VIEW) cfg = load_config() + before = copy.deepcopy(cfg.get('upstream_dns', {})) current = cfg.get('upstream_dns', {}) if cache_size == int(current.get('cache_size', 0)): flash('No changes detected.', 'info') @@ -77,6 +84,10 @@ def upstreamdns_cardforwardingdnsservice_save(): for msg in errors: flash(msg, 'error') return redirect(_VIEW) - save_config(cfg) - flash(queued_msg('core apply'), 'success') + flash(save_config_with_snapshot( + cfg, path='upstream_dns', key='global', operation='edit', + before=before, after=copy.deepcopy(cfg['upstream_dns']), + description='Updated DNS cache size', + cmd='core apply', + ), 'success') return redirect(_VIEW) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 2ae1651..6ac55af 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -421,8 +421,10 @@ def save_config_with_snapshot(new_core, path, key, operation, before, after, description='', cmd='core apply', queue=True): """ Write a .snapshots/{ts}-{uuid}.json file, save new_core to disk, and - optionally create a pending queue entry. Returns a flash message string - when queue=True, otherwise None. + optionally create a pending queue entry. Returns a flash message string. + + queue=False: skips queueing and records the change directly in + .dashboard-done so it appears in Change History without a pending step. """ entry_uuid = str(uuid.uuid4()) entry_ts = int(datetime.now().timestamp()) @@ -450,7 +452,9 @@ def save_config_with_snapshot(new_core, path, key, operation, before, after, save_config(new_core) if not queue: - return None + with open(DASHBOARD_DONE, 'a') as f: + f.write(f'{entry_uuid} {entry_ts}\n') + return 'Saved.' if _apply_changes_immediately(): with open(DASHBOARD_QUEUE, 'a') as f: diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 71bebea..9027c10 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -594,22 +594,27 @@ def collect_tokens(): pending_items = get_dashboard_pending() if pending_items: 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_html = _render_snap_val(snap.get('before') if snap else None) - after_html = _render_snap_val(snap.get('after') if snap else None) + 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'{e(dt_str)}' - f'{snap_desc}' - f'{before_html}' - f'{after_html}' - f'{snap_id}' - f'{e(user)}' - f'') + rows += (f'' + f'' + f'{e(dt_str)}' + f'{snap_desc}' + f'{_render_snap_val(before_val)}' + f'{_render_snap_val(after_val)}' + f'{snap_id}' + f'{e(user)}' + f'' + f'{_snap_expand_row(before_val, after_val, 7)}') select_all = ( '' @@ -636,25 +641,27 @@ def collect_tokens(): done_items = get_dashboard_done() if done_items: hist_rows = '' + _hist_onclick = 'onclick="this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' for _uuid, applied_ts in done_items: snap = load_snapshot_for_uuid(_uuid) if applied_ts: dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') else: dt_str = '-' - snap_desc = e(snap['description']) if snap else '' - before_html = _render_snap_val(snap.get('before') if snap else None) - after_html = _render_snap_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'' + 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 '' + snap_user = e(snap['user']) if snap else '' + hist_rows += (f'' f'{e(dt_str)}' f'{snap_desc}' - f'{before_html}' - f'{after_html}' + f'{_render_snap_val(before_val)}' + f'{_render_snap_val(after_val)}' f'{snap_id}' f'{snap_user}' - f'') + f'' + f'{_snap_expand_row(before_val, after_val, 6)}') history_html = ( '' '' @@ -813,25 +820,42 @@ def e(text): return html_mod.escape(str(text)) -def _render_snap_val(val): - """Return an HTML string for a snapshot before/after cell value.""" +def _snap_text(val): + """Return the plain-text representation of a snapshot before/after value.""" if val is None: return '' if isinstance(val, dict) and len(val) == 1: k, v = next(iter(val.items())) - text = f'{k}: {v}' - elif isinstance(val, (dict, list)): - text = json.dumps(val, separators=(',', ':')) - else: - text = str(val) + return f'{k}: {v}' + if isinstance(val, (dict, list)): + return json.dumps(val, separators=(',', ':')) + return str(val) + + +def _render_snap_val(val): + """Return truncated escaped HTML for a snapshot before/after table cell.""" + text = _snap_text(val) + if not text: + return '' trunc = (text[:23] + '…') if len(text) > 24 else text - if trunc == text: - return e(text) - return (f'
' - f'{e(trunc)}' - f'
'
-            f'{e(json.dumps(val, indent=2) if isinstance(val, (dict, list)) else text)}'
-            f'
') + return e(trunc) + + +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'') def apply_tokens(text, tokens): diff --git a/docker/routlin-dash/data/page_content.json b/docker/routlin-dash/data/page_content.json index 0814f31..aba92ac 100644 --- a/docker/routlin-dash/data/page_content.json +++ b/docker/routlin-dash/data/page_content.json @@ -1428,7 +1428,7 @@ }, { "type": "field", - "label": "Errors Only", + "label": "Only record errors to log", "name": "log_errors_only", "input_type": "checkbox", "value": "%GENERAL_LOG_ERRORS_ONLY%",