Development

This commit is contained in:
Matthew Grotke 2026-05-24 02:28:52 -04:00
parent 8ae6985503
commit c8607ba8c5
3 changed files with 139 additions and 13 deletions

View file

@ -1,6 +1,7 @@
import re
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
@ -87,6 +88,44 @@ def edit_ddns_provider():
return redirect(VIEW)
@bp.route('/action/ddns_cardlog_save', methods=['POST'])
@require_level('administrator')
def ddns_cardlog_save():
log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None)
if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(VIEW)
log_errors_only = 'log_errors_only' in request.form
if not verify_core_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
core = load_core()
core.setdefault('ddns', {}).setdefault('general', {}).update({
'log_max_kb': log_max_kb,
'log_errors_only': log_errors_only,
})
save_core(core)
flash('DDNS log settings saved.', 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardinterval_save', methods=['POST'])
@require_level('administrator')
def ddns_cardinterval_save():
timer_interval = request.form.get('timer_interval', '').strip()
if not re.match(r'^\d+[mhd]$', timer_interval):
flash('Invalid interval. Use a number followed by m, h, or d (e.g. 10m, 1h).', 'error')
return redirect(VIEW)
if not verify_core_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
core = load_core()
core.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)
@bp.route('/action/delete_ddns_provider', methods=['POST'])
@require_level('administrator')
def delete_ddns_provider():

View file

@ -459,7 +459,8 @@ DDNS_LOG_MAX = 50
def _ddns_log_tail():
log_path = f'{CONFIGS_DIR}/ddns.log'
try:
size_kb = os.path.getsize(log_path) / 1024
log_max_kb = _load_ddns().get('general', {}).get('log_max_kb', 1024)
size_kb = os.path.getsize(log_path) / 1024
with open(log_path) as f:
lines = f.readlines()
if not lines:
@ -468,10 +469,11 @@ def _ddns_log_tail():
tail = lines[-DDNS_LOG_MAX:]
shown = len(tail)
hidden = total - shown
size_str = f'{size_kb:.1f} KB'
left = f'Showing last {shown} lines ({hidden} lines not shown)' if hidden > 0 else f'Showing {shown} lines'
pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0
left = f'Showing last {shown} lines ({hidden} lines not shown)' if hidden > 0 else f'Showing {shown} lines'
right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)'
summary = (f'<div class="text-muted" style="display:flex;justify-content:space-between;margin-top:0.5em;">'
f'<span>{left}</span><span>Log file size: {size_str}</span></div>')
f'<span>{left}</span><span>{right}</span></div>')
return ''.join(tail).strip(), summary
except FileNotFoundError:
return '(log file not found)', ''
@ -570,7 +572,7 @@ def _ddns_last_checked():
return f'Last checked: {_relative_time(dt.timestamp())}'
except Exception:
pass
return ''
return 'Last checked: ---'
def _vpn_info():
for vlan in _load_core().get('vlans', []):
@ -676,7 +678,10 @@ def collect_tokens():
tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(core)
ddns = _load_ddns()
tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
ddns_gen = ddns.get('general', {})
tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-')
tokens['DDNS_GEN_LOG_MAX_KB'] = str(ddns_gen.get('log_max_kb', 1024))
tokens['DDNS_GEN_LOG_ERRORS_ONLY'] = 'true' if ddns_gen.get('log_errors_only') else 'false'
enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
_ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'}
@ -827,6 +832,9 @@ def _render_item(item, tokens, inherited_req=None):
if t == 'h1':
return f'<h1>{e(apply_tokens(item.get("text", ""), tokens))}</h1>'
if t == 'hr':
return '<hr/>'
if t == 'p':
text = e(apply_tokens(item.get('text', ''), tokens))
link = item.get('link')
@ -880,11 +888,33 @@ def _render_item(item, tokens, inherited_req=None):
return f'<div class="stat-card-grid">{render_items(item.get("items", []), tokens, req)}</div>'
if t == 'stat_card':
label = e(apply_tokens(item.get('label', ''), tokens))
value = e(apply_tokens(item.get('value', ''), tokens))
sub = e(apply_tokens(item.get('sub', ''), tokens))
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
label = e(apply_tokens(item.get('label', ''), tokens))
raw_value = apply_tokens(item.get('value', ''), tokens)
value = e(raw_value)
sub = e(apply_tokens(item.get('sub', ''), tokens))
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
edit_action = item.get('edit_action', '')
edit_field = item.get('edit_field', '')
if edit_action and edit_field:
return (
f'<div class="{cls} stat-card-editable">'
f'<div class="stat-card-label">{label}</div>'
f'<div class="stat-card-view">'
f'<span class="stat-card-value">{value}</span>'
f'<button type="button" class="btn btn-ghost btn-sm stat-card-edit-btn">Edit</button>'
f'</div>'
f'<form class="stat-card-edit-form" style="display:none" action="{e(edit_action)}" method="post">'
f'<input type="hidden" name="config_hash" value="{e(core_hash())}"/>'
f'<input type="text" name="{e(edit_field)}" value="{e(raw_value)}" class="form-input"/>'
f'<div style="margin-top:0.5em;display:flex;gap:0.5em">'
f'<button type="submit" class="btn btn-primary btn-sm">Save</button>'
f'<button type="button" class="btn btn-secondary btn-sm stat-card-cancel-btn">Cancel</button>'
f'</div>'
f'</form>'
f'<div class="stat-card-sub">{sub}</div>'
f'</div>'
)
return (f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
f'<div class="stat-card-value">{value}</div>'
@ -2547,6 +2577,22 @@ function startApplyPoller(uuid, bar, mine) {
document.querySelectorAll('.pre-block[data-scroll-bottom]').forEach(function(el) {
el.scrollTop = el.scrollHeight;
});
(function() {
document.querySelectorAll('.stat-card-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var card = btn.closest('.stat-card-editable');
card.querySelector('.stat-card-view').style.display = 'none';
card.querySelector('.stat-card-edit-form').style.display = '';
});
});
document.querySelectorAll('.stat-card-cancel-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var card = btn.closest('.stat-card-editable');
card.querySelector('.stat-card-view').style.display = '';
card.querySelector('.stat-card-edit-form').style.display = 'none';
});
});
})();
"""

View file

@ -319,7 +319,9 @@
"type": "stat_card",
"label": "Check Interval",
"value": "%DDNS_TIMER_INTERVAL%",
"sub": "%STAT_PUBLIC_IP_LAST_CHECKED%"
"sub": "%STAT_PUBLIC_IP_LAST_CHECKED%",
"edit_action": "/action/ddns_cardinterval_save",
"edit_field": "timer_interval"
},
{
"type": "stat_card",
@ -476,6 +478,45 @@
"text": "Clear Log"
}
]
},
{
"type": "hr"
},
{
"type": "form",
"action": "/action/ddns_cardlog_save",
"method": "post",
"items": [
{
"type": "field",
"label": "Max Log Size (KB)",
"name": "log_max_kb",
"input_type": "number",
"value": "%DDNS_GEN_LOG_MAX_KB%",
"min": "64"
},
{
"type": "field",
"label": "Log errors only",
"name": "log_errors_only",
"input_type": "checkbox",
"checkbox_label": "Only record errors to log",
"value": "%DDNS_GEN_LOG_ERRORS_ONLY%"
},
{
"type": "button_row",
"items": [
{
"type": "button_primary",
"text": "Save"
},
{
"type": "button_cancel",
"text": "Cancel"
}
]
}
]
}
]
}