Development

This commit is contained in:
Matthew Grotke 2026-05-25 20:46:17 -04:00
parent adcfe55c7c
commit 59d3d65d18
7 changed files with 146 additions and 75 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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'<tr>'
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}"/></td>'
f'<td class="table-cell">{e(dt_str)}</td>'
f'<td class="table-cell">{snap_desc}</td>'
f'<td class="table-cell">{before_html}</td>'
f'<td class="table-cell">{after_html}</td>'
f'<td class="table-cell">{snap_id}</td>'
f'<td class="table-cell">{e(user)}</td>'
f'</tr>')
rows += (f'<tr style="cursor:pointer" {_tr_onclick}>'
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}"/></td>'
f'<td class="table-cell">{e(dt_str)}</td>'
f'<td class="table-cell">{snap_desc}</td>'
f'<td class="table-cell">{_render_snap_val(before_val)}</td>'
f'<td class="table-cell">{_render_snap_val(after_val)}</td>'
f'<td class="table-cell">{snap_id}</td>'
f'<td class="table-cell">{e(user)}</td>'
f'</tr>'
f'{_snap_expand_row(before_val, after_val, 7)}')
select_all = (
'<input type="checkbox" '
'onchange="document.querySelectorAll(\'[name=selected_uuids]\').forEach(c=>c.checked=this.checked)"/>'
@ -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'<tr>'
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'<tr style="cursor:pointer" {_hist_onclick}>'
f'<td class="table-cell">{e(dt_str)}</td>'
f'<td class="table-cell">{snap_desc}</td>'
f'<td class="table-cell">{before_html}</td>'
f'<td class="table-cell">{after_html}</td>'
f'<td class="table-cell">{_render_snap_val(before_val)}</td>'
f'<td class="table-cell">{_render_snap_val(after_val)}</td>'
f'<td class="table-cell">{snap_id}</td>'
f'<td class="table-cell">{snap_user}</td>'
f'</tr>')
f'</tr>'
f'{_snap_expand_row(before_val, after_val, 6)}')
history_html = (
'<table class="data-table" style="margin-bottom:1rem">'
'<thead><tr>'
@ -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'<details style="display:inline">'
f'<summary style="cursor:pointer;list-style:none">{e(trunc)}</summary>'
f'<pre style="margin:0.5rem 0;white-space:pre-wrap;font-size:0.85em">'
f'{e(json.dumps(val, indent=2) if isinstance(val, (dict, list)) else text)}'
f'</pre></details>')
return e(trunc)
def _snap_expand_row(before_val, after_val, colspan):
"""Return a hidden <tr> 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 '<em>(none)</em>'
return f'<div style="flex:1;min-width:0"><strong>{label}</strong><pre style="{pre}">{body}</pre></div>'
inner = f'<div style="display:flex;gap:1rem">{box("Before", before_val)}{box("After", after_val)}</div>'
return (f'<tr hidden>'
f'<td colspan="{colspan}" style="padding:0.5rem 1rem;background:#f8f8f8">'
f'{inner}</td></tr>')
def apply_tokens(text, tokens):

View file

@ -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%",