diff --git a/docker/routlin-dash/app/action_apply_ddns_providers.py b/docker/routlin-dash/app/action_apply_ddns_providers.py
index e2650b6..661fd9a 100644
--- a/docker/routlin-dash/app/action_apply_ddns_providers.py
+++ b/docker/routlin-dash/app/action_apply_ddns_providers.py
@@ -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():
diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py
index 4aa1401..4ad1a70 100644
--- a/docker/routlin-dash/app/view_page.py
+++ b/docker/routlin-dash/app/view_page.py
@@ -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'
'
- f'{left} Log file size: {size_str}
')
+ f'{left} {right} ')
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'{e(apply_tokens(item.get("text", ""), tokens))} '
+ if t == 'hr':
+ return ' '
+
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'{render_items(item.get("items", []), tokens, req)}
'
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''
+ f'
{label}
'
+ f'
'
+ f'{value} '
+ f'Edit '
+ f'
'
+ f'
'
+ f'
{sub}
'
+ f'
'
+ )
return (f''
f'
{label}
'
f'
{value}
'
@@ -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';
+ });
+ });
+})();
"""
diff --git a/docker/routlin-dash/data/page_content.json b/docker/routlin-dash/data/page_content.json
index 1370ff7..dd488ba 100644
--- a/docker/routlin-dash/data/page_content.json
+++ b/docker/routlin-dash/data/page_content.json
@@ -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"
+ }
+ ]
+ }
+ ]
}
]
}