diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 2e4a963..0019dd3 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -14,7 +14,7 @@ DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run' DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending' -SNAPSHOTS_DIR = f'{CONFIGS_DIR}/.snapshots' +DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots' HEALTH_FILE = f'{CONFIGS_DIR}/.health' PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin') DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue' @@ -339,153 +339,225 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'): # Snapshot system =================================================== -def _pending_uuid_set(): - return {item[0] for item in _read_dashboard_pending()} +import re as _re +import sqlite3 as _sqlite3 -def _find_snapshot_dependencies(path, key): - """Return UUIDs of still-pending snapshots that modified the same path+key.""" - try: - pending = _pending_uuid_set() - deps = [] - for fname in sorted(os.listdir(SNAPSHOTS_DIR)): - if not fname.endswith('.json'): - continue - try: - with open(os.path.join(SNAPSHOTS_DIR, fname)) as f: - snap = json.load(f) - if (snap.get('path') == path - and snap.get('key') == str(key) - and snap.get('uuid') in pending): - deps.append(snap['uuid']) - except Exception: - pass - return deps - except Exception: - return [] +def _db(): + conn = _sqlite3.connect(DASHBOARD_DB) + conn.row_factory = _sqlite3.Row + conn.execute('PRAGMA journal_mode=WAL') + conn.executescript(''' + CREATE TABLE IF NOT EXISTS groups ( + uuid TEXT PRIMARY KEY, + ts INTEGER NOT NULL, + cmd TEXT, + user TEXT, + parent_path TEXT NOT NULL, + item_key TEXT, + item_value TEXT, + reverts_group TEXT + ); + CREATE TABLE IF NOT EXISTS changes ( + group_id TEXT NOT NULL REFERENCES groups(uuid), + field TEXT NOT NULL, + before TEXT, + after TEXT, + value_type TEXT NOT NULL, + PRIMARY KEY (group_id, field) + ); + CREATE INDEX IF NOT EXISTS idx_changes_group ON changes(group_id); + ''') + return conn -def _items_match(item, ref): - """Return True if item and ref refer to the same entity by a common identifier field.""" - if not isinstance(item, dict) or not isinstance(ref, dict): - return item == ref - for field in ('ip', 'name', 'mac_address', 'host', 'id', 'address'): - if field in ref and field in item: - return item[field] == ref[field] - return item == ref +def _py_value_type(val): + if val is None: return 'null' + if isinstance(val, bool): return 'bool' + if isinstance(val, int): return 'int' + if isinstance(val, float): return 'float' + if isinstance(val, (dict, list)): return 'json' + return 'str' -def revert_snapshot_to_config(entry_uuid): - """Apply the inverse of a snapshot to config.json and queue a new pending change. +def _serialize_value(val): + if val is None: + return None + if isinstance(val, bool): + return 'true' if val else 'false' + if isinstance(val, (dict, list)): + return json.dumps(val, separators=(',', ':')) + return str(val) - Returns (flash_message, success_bool). + +def _deserialize_value(text, value_type): + if text is None: + return None + if value_type == 'int': return int(text) + if value_type == 'float': return float(text) + if value_type == 'bool': return text == 'true' + if value_type in ('json', 'null'): return json.loads(text) + return text + + +def diff_fields(before_dict, after_dict): + """Return list of (field, before_text, after_text, value_type) for changed fields.""" + bd = before_dict or {} + ad = after_dict or {} + result = [] + for key in sorted(set(bd) | set(ad)): + bval = bd.get(key) + aval = ad.get(key) + if bval == aval: + continue + ref = aval if aval is not None else bval + result.append(( + key, + _serialize_value(bval), + _serialize_value(aval), + _py_value_type(ref), + )) + return result + + +_PATH_SEG = _re.compile(r'([^\.\[]+)(?:\[([^\]=]+)=([^\]]+)\])?') + + +def _parse_path(path): + """Parse 'vlans[name=trusted].field' into [(field, sel_key, sel_val), ...].""" + return [(m.group(1), m.group(2), m.group(3)) for m in _PATH_SEG.finditer(path)] + + +def _nav_get(cfg, path): + """Navigate config to the value at path.""" + for field, sel_key, sel_val in _parse_path(path): + cfg = cfg[field] + if sel_key: + cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val)) + return cfg + + +def _nav_parent(cfg, path): + """Return (parent_obj, final_key) for setting/deleting the last path segment.""" + segs = _parse_path(path) + for field, sel_key, sel_val in segs[:-1]: + cfg = cfg[field] + if sel_key: + cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val)) + return cfg, segs[-1][0] + + +def record_group(cfg, parent_path, item_key, item_value, changes, cmd, + reverts_group=None, queue=True): + """Insert a group + changes into sqlite, save config, and queue the command. + + Returns a flash message string. """ - snap = load_snapshot_for_uuid(entry_uuid) - if not snap: - return f'Snapshot not found for {entry_uuid[:8]}.', False - - path = snap['path'] - key = snap['key'] - before = snap['before'] # original state to restore - after = snap['after'] # applied state to undo - operation = snap['operation'] - - if operation == 'revert': - return 'This change is already a revert; cannot revert again.', False - - core = load_config() - - if key == 'global': - if before is None: - core.pop(path, None) - else: - core[path] = before - else: - items = core.setdefault(path, []) - if operation == 'add': - core[path] = [x for x in items if not _items_match(x, after)] - elif operation == 'delete': - if before: - core[path].append(before) - else: - if before and after: - for i, item in enumerate(items): - if _items_match(item, after): - items[i] = before - break - - msg = save_config_with_snapshot( - core, path=path, key=key, operation='revert', - before=after, after=before, - description=f"Reverted: {snap.get('description', '')}", - cmd=snap.get('cmd', 'core apply'), - reverts=entry_uuid, - ) - return msg or 'Reverted.', True - - -def load_snapshot_for_uuid(entry_uuid): - """Return the snapshot dict for the given UUID, or None if not found.""" - try: - for fname in os.listdir(SNAPSHOTS_DIR): - if fname.endswith(f'-{entry_uuid}.json'): - with open(os.path.join(SNAPSHOTS_DIR, fname)) as f: - return json.load(f) - except Exception: - pass - return None - - -def save_config_with_snapshot(new_config, path, key, operation, before, after, - description='', cmd='core apply', queue=True, reverts=None): - """ - Write a .snapshots/{ts}-{uuid}.json file, save new_config to disk, and - 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()) + group_uuid = str(uuid.uuid4()) entry_ts = int(datetime.now().timestamp()) current_user = session.get('email_address', 'unknown') - depends_on = _find_snapshot_dependencies(path, key) + conn = _db() + try: + conn.execute( + 'INSERT INTO groups ' + '(uuid,ts,cmd,user,parent_path,item_key,item_value,reverts_group) ' + 'VALUES (?,?,?,?,?,?,?,?)', + (group_uuid, entry_ts, cmd, current_user, + parent_path, item_key, item_value, reverts_group) + ) + for field, before, after, value_type in changes: + conn.execute( + 'INSERT INTO changes (group_id,field,before,after,value_type) ' + 'VALUES (?,?,?,?,?)', + (group_uuid, field, before, after, value_type) + ) + conn.commit() + finally: + conn.close() - os.makedirs(SNAPSHOTS_DIR, exist_ok=True) - snapshot = { - 'uuid': entry_uuid, - 'ts': entry_ts, - 'cmd': cmd, - 'user': current_user, - 'operation': operation, - 'description': description, - 'path': path, - 'key': str(key), - 'before': before, - 'after': after, - 'depends_on': depends_on, - 'reverts': reverts, - } - with open(os.path.join(SNAPSHOTS_DIR, f'{entry_ts}-{entry_uuid}.json'), 'w') as f: - json.dump(snapshot, f, indent=2) - - save_config(new_config) + save_config(cfg) if not queue: with open(DASHBOARD_DONE, 'a') as f: - f.write(f'{entry_uuid} {entry_ts}\n') + f.write(f'{group_uuid} {entry_ts}\n') return 'Saved.' if _apply_changes_immediately(): with open(DASHBOARD_QUEUE, 'a') as f: - f.write(f'{entry_uuid} {entry_ts} [{cmd}] ({current_user})\n') + f.write(f'{group_uuid} {entry_ts} [{cmd}] ({current_user})\n') _trim_if_needed() else: - _queue_pending_presigned(cmd, entry_uuid, entry_ts) + _queue_pending_presigned(cmd, group_uuid, entry_ts) return _build_timing_msg(entry_ts) +def load_all_groups(): + """Return list of (group_dict, [change_dicts]) sorted newest first.""" + conn = _db() + try: + gs = conn.execute('SELECT * FROM groups ORDER BY ts DESC').fetchall() + result = [] + for g in gs: + cs = conn.execute( + 'SELECT * FROM changes WHERE group_id=? ORDER BY field', (g['uuid'],) + ).fetchall() + result.append((dict(g), [dict(c) for c in cs])) + return result + finally: + conn.close() + + +def revert_group(group_uuid): + """Revert a change group. Returns (flash_message, success_bool).""" + 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]}.', False + g = dict(g) + changes = [dict(c) for c in conn.execute( + 'SELECT * FROM changes WHERE group_id=?', (group_uuid,) + ).fetchall()] + finally: + conn.close() + + if g['reverts_group']: + return 'Cannot revert a revert.', False + + cfg = load_config() + parent_path = g['parent_path'] + item_key = g['item_key'] + item_value = g['item_value'] + + all_before_null = all(c['before'] is None for c in changes) + all_after_null = all(c['after'] is None for c in changes) + + if all_before_null: + parent_obj, lst_key = _nav_parent(cfg, parent_path) + parent_obj[lst_key] = [ + x for x in parent_obj[lst_key] + if str(x.get(item_key, '')) != str(item_value) + ] + elif all_after_null: + item = {c['field']: _deserialize_value(c['before'], c['value_type']) for c in changes} + _nav_get(cfg, parent_path).append(item) + else: + item_path = f'{parent_path}[{item_key}={item_value}]' if item_key else parent_path + for c in changes: + parent_obj, field = _nav_parent(cfg, f'{item_path}.{c["field"]}') + if c['before'] is None: + parent_obj.pop(field, None) + else: + parent_obj[field] = _deserialize_value(c['before'], c['value_type']) + + 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) + return msg, True + + # Misc ============================================================== def run_apply(): diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index 0bf5e9b..373c1e3 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -92,40 +92,41 @@ def passes(req, level): # Snapshot helpers ==================================================== -def snap_text(val): - """Return the plain-text representation of a snapshot before/after value.""" - if val is None: +def build_snap_val(changes): + """Return a brief summary of changed field names for the history table cell.""" + if not changes: return '' - if isinstance(val, dict) and len(val) == 1: - k, v = next(iter(val.items())) - return f'{k}: {v}' - if isinstance(val, (dict, list)): - return json.dumps(val, separators=(',', ':')) - return str(val) + fields = [c['field'] for c in changes] + if len(fields) <= 2: + return e(', '.join(fields)) + return e(f'{fields[0]}, {fields[1]} (+{len(fields) - 2} more)') -def build_snap_val(val): - """Return truncated escaped HTML for a snapshot before/after table cell.""" - text = snap_text(val) - if not text: +def snap_expand_row(changes, colspan): + """Return a hidden with a per-field change table.""" + if not changes: return '' - trunc = (text[:23] + '…') if len(text) > 24 else text - return e(trunc) - - -def snap_expand_row(before_val, after_val, colspan): - """Return a hidden that expands with full before/after content.""" - 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}' - f'
{body}
' + rows = '' + for c in changes: + bval = c['before'] if c['before'] is not None else '' + aval = c['after'] if c['after'] is not None else '' + rows += ( + '' + f'{e(c["field"])}' + f'{e(bval) if bval else "(none)"}' + f'{e(aval) if aval else "(none)"}' + '' ) - inner = f'
{box("Before", before_val)}{box("After", after_val)}
' + inner = ( + '' + '' + '' + '' + '' + '' + f'{rows}' + '
FieldBeforeAfter
' + ) return f'{inner}' # Form helpers ======================================================== diff --git a/docker/routlin-dash/app/pages/actions/action.py b/docker/routlin-dash/app/pages/actions/action.py index 9b37b75..085e0df 100644 --- a/docker/routlin-dash/app/pages/actions/action.py +++ b/docker/routlin-dash/app/pages/actions/action.py @@ -1,10 +1,9 @@ -import os 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_snapshot_to_config, queued_msg, - SNAPSHOTS_DIR, DASHBOARD_PENDING) + revert_group, queued_msg, + DASHBOARD_PENDING, _db) _PAGE = Path(__file__).parent.name @@ -40,7 +39,7 @@ def history_revert(): return redirect(f'/{_PAGE}') succeeded, failed = 0, 0 for uuid in selected_uuids: - msg, ok = revert_snapshot_to_config(uuid) + msg, ok = revert_group(uuid) if ok: succeeded += 1 else: @@ -60,14 +59,15 @@ def history_clear(): flash('No items selected.', 'info') return redirect(f'/{_PAGE}') count = 0 - for fname in os.listdir(SNAPSHOTS_DIR): - if not fname.endswith('.json'): - continue - if any(fname.endswith(f'-{uuid}.json') for uuid in selected_uuids): - fpath = os.path.join(SNAPSHOTS_DIR, fname) - if os.path.isfile(fpath): - os.remove(fpath) - count += 1 + conn = _db() + try: + for uid in selected_uuids: + conn.execute('DELETE FROM changes WHERE group_id=?', (uid,)) + result = conn.execute('DELETE FROM groups WHERE uuid=?', (uid,)) + count += result.rowcount + conn.commit() + finally: + conn.close() plural = 's' if count != 1 else '' flash(f'{count} history record{plural} cleared.', 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/bannedips/action.py b/docker/routlin-dash/app/pages/bannedips/action.py index 01c3ffc..1a2767d 100644 --- a/docker/routlin-dash/app/pages/bannedips/action.py +++ b/docker/routlin-dash/app/pages/bannedips/action.py @@ -3,7 +3,7 @@ import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -56,12 +56,8 @@ def addip_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='banned_ips', key=ip, operation='add', - before=None, after=entry, - description=f'Added banned IP: {ip}', - ), 'success') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -82,6 +78,7 @@ def table_toggle(): return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) + before = copy.deepcopy(items[idx]) items[idx]['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -89,13 +86,9 @@ def table_toggle(): flash(msg, 'error') return redirect(f'/{_PAGE}') - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path='banned_ips', key=items[idx]['ip'], operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} banned IP: {items[idx]["ip"]}', - ), 'success') + ip = items[idx]['ip'] + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -130,12 +123,8 @@ def table_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='banned_ips', key=ip, operation='edit', - before=before, after=copy.deepcopy(items[idx]), - description=f'Edited banned IP: {ip}', - ), 'success') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -162,10 +151,6 @@ def table_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='banned_ips', key=removed['ip'], operation='delete', - before=removed, after=None, - description=f'Deleted banned IP: {removed["ip"]}', - ), 'success') + changes = diff_fields(removed, None) + flash(record_group(cfg, 'banned_ips', 'ip', removed['ip'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/ddns/action.py b/docker/routlin-dash/app/pages/ddns/action.py index 0222cac..e4c6c22 100644 --- a/docker/routlin-dash/app/pages/ddns/action.py +++ b/docker/routlin-dash/app/pages/ddns/action.py @@ -3,7 +3,7 @@ import copy import os from flask import Blueprint, request, redirect, flash, send_file, abort from auth import require_level -from config_utils import load_config, verify_config_hash, save_config_with_snapshot, CONFIGS_DIR +from config_utils import load_config, verify_config_hash, record_group, diff_fields, CONFIGS_DIR import sanitize import validation as validate @@ -49,13 +49,8 @@ def addaccount_add(): cfg = load_config() cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry) - flash(save_config_with_snapshot( - cfg, path='ddns', key=description, operation='add', - before=None, after=copy.deepcopy(entry), - description=f'Added DDNS provider: {description}', - cmd='ddns update', - queue=False, - ), 'success') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -101,13 +96,8 @@ def accounts_edit(): entry['api_token'] = request.form.get('api_token', '').strip() providers[row_index] = entry - flash(save_config_with_snapshot( - cfg, path='ddns', key=description, operation='edit', - before=before, after=copy.deepcopy(entry), - description=f'Edited DDNS provider: {description}', - cmd='ddns update', - queue=False, - ), 'success') + changes = diff_fields(before, entry) + flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -133,13 +123,8 @@ def accounts_delete(): before = copy.deepcopy(providers[row_index]) description = before.get('description', str(row_index)) del providers[row_index] - flash(save_config_with_snapshot( - cfg, path='ddns', key=description, operation='delete', - before=before, after=None, - description=f'Deleted DDNS provider: {description}', - cmd='ddns update', - queue=False, - ), 'success') + changes = diff_fields(before, None) + flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -163,12 +148,8 @@ def ipcheckinterval_save(): cfg = load_config() before = copy.deepcopy(cfg.get('ddns', {}).get('general', {})) cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval - flash(save_config_with_snapshot( - cfg, path='ddns', key='general', operation='edit', - before=before, after=copy.deepcopy(cfg['ddns']['general']), - description='Updated DDNS check interval', - cmd='core apply', - ), 'success') + changes = diff_fields(before, cfg['ddns']['general']) + flash(record_group(cfg, 'ddns.general', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -191,13 +172,8 @@ def ipcheckservices_save(): services = [{'type': 'http', 'url': u} for u in http_services] services += [{'type': 'dig', 'url': u} for u in dig_services] cfg.setdefault('ddns', {})['ip_check_services'] = services - flash(save_config_with_snapshot( - cfg, path='ddns', key='ip_check_services', operation='edit', - before=before, after=copy.deepcopy(services), - description='Updated DDNS IP check services', - cmd='ddns update', - queue=False, - ), 'success') + changes = diff_fields({'ip_check_services': before}, {'ip_check_services': services}) + flash(record_group(cfg, 'ddns', None, None, changes, 'ddns update', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -220,13 +196,8 @@ def logging_save(): 'log_max_kb': log_max_kb, 'log_errors_only': log_errors_only, }) - flash(save_config_with_snapshot( - cfg, path='ddns', key='general', operation='edit', - before=before, after=copy.deepcopy(cfg['ddns']['general']), - description='Updated DDNS logging settings', - cmd='ddns update', - queue=False, - ), 'success') + changes = diff_fields(before, cfg['ddns']['general']) + flash(record_group(cfg, 'ddns.general', None, None, changes, 'ddns update', queue=False), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dhcp/action.py b/docker/routlin-dash/app/pages/dhcp/action.py index 2c1335b..2348c2f 100644 --- a/docker/routlin-dash/app/pages/dhcp/action.py +++ b/docker/routlin-dash/app/pages/dhcp/action.py @@ -4,7 +4,7 @@ import ipaddress from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -114,12 +114,8 @@ def addreservation_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.reservations', key=mac, operation='add', - before=None, after=entry, - description=f'Added DHCP reservation: {hostname or mac} ({ip or "dynamic"})', - ), 'success') + changes = diff_fields(None, entry) + flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -142,6 +138,7 @@ def reservations_toggle(): res = vlans[vi]['reservations'][ri] old_enabled = res.get('enabled', True) + before = copy.deepcopy(res) res['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -150,13 +147,8 @@ def reservations_toggle(): return redirect(f'/{_PAGE}') vlan_name = vlans[vi]['name'] - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}', - ), 'success') + changes = diff_fields(before, res) + flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', res['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -211,12 +203,8 @@ def reservations_edit(): return redirect(f'/{_PAGE}') vlan_name = vlans[vi]['name'] - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit', - before=before, after=copy.deepcopy(res), - description=f'Edited DHCP reservation: {hostname or mac} ({ip or "dynamic"})', - ), 'success') + changes = diff_fields(before, res) + flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -245,10 +233,6 @@ def reservations_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete', - before=removed, after=None, - description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}', - ), 'success') + changes = diff_fields(removed, None) + flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', removed['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dnsblocking/action.py b/docker/routlin-dash/app/pages/dnsblocking/action.py index c661c93..850c6b6 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/action.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -3,7 +3,7 @@ import copy import re from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg +from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg import sanitize import validation as validate @@ -78,12 +78,8 @@ def blocklists_delete(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, None) + flash(record_group(cfg, 'dns_blocking.blocklists', 'name', name, changes, 'core apply', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -120,12 +116,8 @@ def blocklists_edit(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'dns_blocking.blocklists', 'name', fields['name'], changes, 'core apply', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -159,12 +151,8 @@ def addblocklist_add(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'dns_blocking.blocklists', 'name', fields['name'], changes, 'core apply', queue=False), 'success') return redirect(f'/{_PAGE}') @@ -184,12 +172,8 @@ def blocklistrefresh_save(): 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 - 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') + changes = diff_fields(before, cfg['dns_blocking']['general']) + flash(record_group(cfg, 'dns_blocking.general', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -226,10 +210,6 @@ def logging_save(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, cfg['dns_blocking']['general']) + flash(record_group(cfg, 'dns_blocking.general', None, None, changes, 'core apply', queue=False), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dnsserver/action.py b/docker/routlin-dash/app/pages/dnsserver/action.py index 5d965f1..122d8da 100644 --- a/docker/routlin-dash/app/pages/dnsserver/action.py +++ b/docker/routlin-dash/app/pages/dnsserver/action.py @@ -2,7 +2,7 @@ from pathlib import Path import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -50,12 +50,8 @@ def upstreamdns_save(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, cfg['upstream_dns']) + flash(record_group(cfg, 'upstream_dns', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -84,10 +80,6 @@ def dnsforwarding_save(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, cfg['upstream_dns']) + flash(record_group(cfg, 'upstream_dns', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/hostoverrides/action.py b/docker/routlin-dash/app/pages/hostoverrides/action.py index e67c4df..557b2aa 100644 --- a/docker/routlin-dash/app/pages/hostoverrides/action.py +++ b/docker/routlin-dash/app/pages/hostoverrides/action.py @@ -4,7 +4,7 @@ import ipaddress from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -74,12 +74,8 @@ def addoverride_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='host_overrides', key=host, operation='add', - before=None, after=entry, - description=f'Added host override: {host} → {ip}', - ), 'success') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -100,6 +96,7 @@ def table_toggle(): return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) + before = copy.deepcopy(items[idx]) items[idx]['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -107,13 +104,9 @@ def table_toggle(): flash(msg, 'error') return redirect(f'/{_PAGE}') - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path='host_overrides', key=items[idx]['host'], operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} host override: {items[idx]["host"]}', - ), 'success') + host = items[idx]['host'] + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -154,12 +147,8 @@ def table_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='host_overrides', key=host, operation='edit', - before=before, after=copy.deepcopy(items[idx]), - description=f'Edited host override: {host} → {ip}', - ), 'success') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -186,10 +175,6 @@ def table_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='host_overrides', key=removed['host'], operation='delete', - before=removed, after=None, - description=f'Deleted host override: {removed["host"]}', - ), 'success') + changes = diff_fields(removed, None) + flash(record_group(cfg, 'host_overrides', 'host', removed['host'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/intervlan/action.py b/docker/routlin-dash/app/pages/intervlan/action.py index 8e1d057..70112d3 100644 --- a/docker/routlin-dash/app/pages/intervlan/action.py +++ b/docker/routlin-dash/app/pages/intervlan/action.py @@ -3,7 +3,7 @@ import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -73,10 +73,6 @@ def _parse_entry(): }, None -def _entry_key(entry): - port = f':{entry["dst_port"]}' if entry.get('dst_port') else '' - return f'{entry["protocol"]}:{entry["src_ip_or_subnet"]}→{entry["dst_ip_or_subnet"]}{port}' - @bp.route('/action/intervlan/addexception_add', methods=['POST']) @require_level('administrator') @@ -95,13 +91,9 @@ def addexception_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = _entry_key(entry) - flash(save_config_with_snapshot( - cfg, - path='inter_vlan_exceptions', key=key, operation='add', - before=None, after=entry, - description=f'Added inter-VLAN rule: {key}', - ), 'success') + src = entry.get('src_ip_or_subnet', '') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -122,6 +114,7 @@ def table_toggle(): return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) + before = copy.deepcopy(items[idx]) items[idx]['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -129,14 +122,9 @@ def table_toggle(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = _entry_key(items[idx]) - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path='inter_vlan_exceptions', key=key, operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} inter-VLAN rule: {key}', - ), 'success') + src = items[idx].get('src_ip_or_subnet', '') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -169,13 +157,9 @@ def table_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = _entry_key(entry) - flash(save_config_with_snapshot( - cfg, - path='inter_vlan_exceptions', key=key, operation='edit', - before=before, after=copy.deepcopy(items[idx]), - description=f'Edited inter-VLAN rule: {key}', - ), 'success') + src = items[idx].get('src_ip_or_subnet', '') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -202,11 +186,7 @@ def table_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = _entry_key(removed) - flash(save_config_with_snapshot( - cfg, - path='inter_vlan_exceptions', key=key, operation='delete', - before=removed, after=None, - description=f'Deleted inter-VLAN rule: {key}', - ), 'success') + src = removed.get('src_ip_or_subnet', '') + changes = diff_fields(removed, None) + flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/mdns/action.py b/docker/routlin-dash/app/pages/mdns/action.py index 38887f9..c2bb9b1 100644 --- a/docker/routlin-dash/app/pages/mdns/action.py +++ b/docker/routlin-dash/app/pages/mdns/action.py @@ -3,7 +3,7 @@ import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -38,10 +38,6 @@ def settings_apply(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='mdns_reflection', key='global', operation='edit', - before=before or None, after=copy.deepcopy(cfg['mdns_reflection']), - description='Updated mDNS reflection settings', - ), 'success') + changes = diff_fields(before, cfg['mdns_reflection']) + flash(record_group(cfg, 'mdns_reflection', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index a5ed70d..3e657b1 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -5,7 +5,7 @@ import json from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -13,9 +13,6 @@ _PAGE = Path(__file__).parent.name bp = Blueprint(_PAGE, __name__) -_VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries', - 'radius_default', 'mdns_reflection', 'use_blocklists'] - def _row_index(): try: @@ -205,12 +202,8 @@ def addvlan_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='vlans', key=name, operation='add', - before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry}, - description=f'Added VLAN: {name} ({subnet}/{subnet_mask})', - ), 'success') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'vlans', 'name', name, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -404,7 +397,7 @@ def vlans_edit(): flash('No changes were made.', 'info') return redirect(f'/{_PAGE}') - before = {k: existing.get(k) for k in _VLAN_FIELDS} + before = copy.deepcopy(existing) existing.update({ 'name': name, 'vlan_id': vlan_id, @@ -438,12 +431,8 @@ def vlans_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='vlans', key=name, operation='edit', - before=before, after={k: existing.get(k) for k in _VLAN_FIELDS}, - description=f'Edited VLAN: {name}', - ), 'success') + changes = diff_fields(before, existing) + flash(record_group(cfg, 'vlans', 'name', name, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -470,11 +459,6 @@ def vlans_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - flash(save_config_with_snapshot( - cfg, - path='vlans', key=removed['name'], operation='delete', - before={k: removed.get(k) for k in _VLAN_FIELDS}, - after=None, - description=f'Deleted VLAN: {removed["name"]}', - ), 'success') + changes = diff_fields(removed, None) + flash(record_group(cfg, 'vlans', 'name', removed['name'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/action.py b/docker/routlin-dash/app/pages/physicalinterfaces/action.py index 4277768..278997f 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/action.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/action.py @@ -4,7 +4,7 @@ import os from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg, queue_command +from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg, queue_command import sanitize import validation as validate @@ -66,12 +66,8 @@ def physicalinterface_save(): for msg in errors: flash(msg, 'error') return redirect(f'/{_PAGE}') - 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') + changes = diff_fields(before, cfg['network_interfaces']) + flash(record_group(cfg, 'network_interfaces', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/portforwarding/action.py b/docker/routlin-dash/app/pages/portforwarding/action.py index 9b7a6a5..37fc237 100644 --- a/docker/routlin-dash/app/pages/portforwarding/action.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -3,7 +3,7 @@ import copy from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash +from config_utils import load_config, record_group, diff_fields, verify_config_hash import sanitize import validation as validate @@ -91,13 +91,9 @@ def addrule_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = f'{entry["protocol"]}:{entry["dest_port"]}' - flash(save_config_with_snapshot( - cfg, - path='port_forwarding', key=key, operation='add', - before=None, after=entry, - description=f'Added port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}', - ), 'success') + dest_port = entry.get('dest_port', '') + changes = diff_fields(None, entry) + flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -118,6 +114,7 @@ def rules_toggle(): return redirect(f'/{_PAGE}') old_enabled = items[idx].get('enabled', True) + before = copy.deepcopy(items[idx]) items[idx]['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -125,14 +122,9 @@ def rules_toggle(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}' - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path='port_forwarding', key=key, operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} port forward: {key}', - ), 'success') + dest_port = items[idx].get('dest_port', '') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -165,13 +157,9 @@ def rules_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = f'{entry["protocol"]}:{entry["dest_port"]}' - flash(save_config_with_snapshot( - cfg, - path='port_forwarding', key=key, operation='edit', - before=before, after=copy.deepcopy(items[idx]), - description=f'Edited port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}', - ), 'success') + dest_port = items[idx].get('dest_port', '') + changes = diff_fields(before, items[idx]) + flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -198,11 +186,7 @@ def rules_delete(): flash(msg, 'error') return redirect(f'/{_PAGE}') - key = f'{removed["protocol"]}:{removed["dest_port"]}' - flash(save_config_with_snapshot( - cfg, - path='port_forwarding', key=key, operation='delete', - before=removed, after=None, - description=f'Deleted port forward: {key}', - ), 'success') + dest_port = removed.get('dest_port', '') + changes = diff_fields(removed, None) + flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/vpn/action.py b/docker/routlin-dash/app/pages/vpn/action.py index d232671..67a69e8 100644 --- a/docker/routlin-dash/app/pages/vpn/action.py +++ b/docker/routlin-dash/app/pages/vpn/action.py @@ -6,7 +6,7 @@ import re from flask import Blueprint, make_response, redirect, flash, request from auth import require_level -from config_utils import load_config, save_config_with_snapshot, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME +from config_utils import load_config, record_group, diff_fields, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME import sanitize import validation as validate @@ -200,12 +200,8 @@ def wireguard_apply(): return redirect(f'/{_PAGE}') vlan_name = vpn_vlan['name'] - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.vpn_information', key=vlan_name, operation='edit', - before=before_info or None, after=copy.deepcopy(info), - description=f'Updated VPN configuration for {vlan_name}', - ), 'success') + changes = diff_fields(before_info, info) + flash(record_group(cfg, f'vlans[name={vlan_name}].vpn_information', None, None, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -271,13 +267,8 @@ def addpeer_add(): flash(msg, 'error') return redirect(f'/{_PAGE}') - save_config_with_snapshot( - cfg, - path=f'vlans.{peer_vlan_nm}.peers', key=peer_name, operation='add', - before=None, after={k: v for k, v in entry.items() if k != 'public_key'}, - description=f'Added VPN peer: {peer_name} ({peer_ip})', - queue=True, - ) + changes = diff_fields(None, entry) + record_group(cfg, f'vlans[name={peer_vlan_nm}].peers', 'name', peer_name, changes, 'core apply') return _conf_response(vpn_vlan, peer_name, peer_ip, private_key) @@ -319,12 +310,8 @@ def peers_edit(): return redirect(f'/{_PAGE}') vlan_name = vlan['name'] - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.peers', key=peer_name, operation='edit', - before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}, - description=f'Edited VPN peer: {peer_name}', - ), 'success') + changes = diff_fields(before, {'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}) + flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer_name, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -346,6 +333,7 @@ def peers_toggle(): peers = vlan.get('peers', []) old_enabled = peers[peer_idx].get('enabled', True) + before = copy.deepcopy(peers[peer_idx]) peers[peer_idx]['enabled'] = not old_enabled errors = validate.validate_config(cfg) if errors: @@ -355,13 +343,8 @@ def peers_toggle(): peer_name = peers[peer_idx]['name'] vlan_name = vlan['name'] - action = 'Enabled' if not old_enabled else 'Disabled' - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.peers', key=peer_name, operation='toggle', - before={'enabled': old_enabled}, after={'enabled': not old_enabled}, - description=f'{action} VPN peer: {peer_name}', - ), 'success') + changes = diff_fields(before, peers[peer_idx]) + flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer_name, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -390,13 +373,8 @@ def peers_delete(): return redirect(f'/{_PAGE}') vlan_name = vlan['name'] - flash(save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.peers', key=removed['name'], operation='delete', - before={k: removed.get(k) for k in ('name', 'ip', 'split_tunnel', 'enabled')}, - after=None, - description=f'Deleted VPN peer: {removed["name"]}', - ), 'success') + changes = diff_fields(removed, None) + flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', removed['name'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -427,11 +405,6 @@ def peers_regenerate(): return redirect(f'/{_PAGE}') vlan_name = vlan['name'] - save_config_with_snapshot( - cfg, - path=f'vlans.{vlan_name}.peers', key=peer['name'], operation='regenerate', - before={'public_key': old_pub_key}, after={'public_key': public_key}, - description=f'Regenerated keypair for VPN peer: {peer["name"]}', - queue=True, - ) + changes = diff_fields({'public_key': old_pub_key}, {'public_key': public_key}) + record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer['name'], changes, 'core apply') return _conf_response(vlan, peer['name'], peer['ip'], private_key) diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 2c72bd5..c696f1f 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -4,7 +4,7 @@ import json, re, subprocess, os, sys import sanitize import validation as validate from datetime import datetime, timezone -from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, load_all_snapshots, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR +from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_all_groups, revert_group, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR import factory from factory import LEVEL_RANK, e, client_level, passes, build_items, build_snap_val, snap_expand_row PAGES_DIR = os.path.join(APP_DIR, 'pages') @@ -634,19 +634,18 @@ def collect_tokens(): except Exception: pass - all_snaps = load_all_snapshots() - _snap_uuid_set = {s.get('uuid') for s in all_snaps} + all_groups = load_all_groups() # [(group_dict, [change_dicts])] + _group_uuid_set = {g['uuid'] for g, _ in all_groups} 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) + _pgroups = defaultdict(list) for _uuid, _ts, cmd, user in pending_items: - groups[cmd].append((_uuid, user)) + _pgroups[cmd].append((_uuid, user)) rows = '' - for cmd, entries in groups.items(): + for cmd, entries in _pgroups.items(): users = ', '.join(sorted({u for _, u in entries if u and u != 'unknown'})) - snap_uuids = [_uuid for _uuid, _ in entries if _uuid in _snap_uuid_set] + snap_uuids = [_uuid for _uuid, _ in entries if _uuid in _group_uuid_set] if snap_uuids: req_tags = ''.join( f'' @@ -690,27 +689,33 @@ def collect_tokens(): if pending_items else '' ) done_ts_map = get_done_timestamps() - if all_snaps: - # UUIDs that cannot be reverted: revert entries themselves, and entries - # that have already been reverted (referenced in another snap's 'reverts'). + if all_groups: _no_revert = set() - for _s in all_snaps: - if _s.get('operation') == 'revert': - _no_revert.add(_s.get('uuid', '')) - if _s.get('reverts'): - _no_revert.add(_s['reverts']) + for g, _ in all_groups: + if g['reverts_group']: + _no_revert.add(g['uuid']) + _no_revert.add(g['reverts_group']) hist_rows = '' _hist_onclick = ( 'onclick="if(event.target.type!==\'checkbox\')' 'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' ) - for snap in all_snaps: - _uuid = snap.get('uuid', '') + for g, changes in all_groups: + _uuid = g['uuid'] applied_ts = done_ts_map.get(_uuid) dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-' - snap_desc = e(snap.get('description', '')) - before_val = snap.get('before') - after_val = snap.get('after') + all_before_null = all(c['before'] is None for c in changes) + all_after_null = all(c['after'] is None for c in changes) + if g['reverts_group']: + verb = 'Reverted' + elif all_before_null: + verb = 'Added' + elif all_after_null: + verb = 'Deleted' + else: + verb = 'Edited' + item = g.get('item_value') or '' + summary = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}' snap_tag = ( f'
' f'{e(_uuid[:8])}' @@ -718,19 +723,18 @@ def collect_tokens(): f'{e(_uuid[:8])}' '
' ) - snap_user = e(snap.get('user', '')) + snap_user = e(g.get('user', '')) _cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else '' hist_rows += ( f'' f'' f'{e(dt_str)}' - f'{snap_desc}' - f'{build_snap_val(before_val)}' - f'{build_snap_val(after_val)}' + f'{e(summary)}' + f'{build_snap_val(changes)}' f'{snap_tag}' f'{snap_user}' '' - f'{snap_expand_row(before_val, after_val, 7)}' + f'{snap_expand_row(changes, 6)}' ) select_all = ( '' f'{select_all}' 'Applied' - 'Description' - 'Before' - 'After' - 'Snapshot' + 'Change' + 'Fields' + 'Group' 'User' '' f'{hist_rows}' @@ -754,7 +757,7 @@ def collect_tokens(): history_html = '

No change history.

' tokens['CHANGE_HISTORY_HTML'] = history_html - tokens['NO_HISTORY'] = 'true' if not all_snaps else '' + tokens['NO_HISTORY'] = 'true' if not all_groups else '' servers = dns.get('upstream_servers', []) tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'