Development

This commit is contained in:
Matthew Grotke 2026-05-27 20:56:30 -04:00
parent d8d1d46fd2
commit eed1d295dc
69 changed files with 3355 additions and 3230 deletions

View file

@ -4,7 +4,9 @@ import json, re, subprocess, os, sys, html as html_mod
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
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
import os as _os
_PAGES_DIR = _os.path.join(APP_DIR, 'pages')
bp = Blueprint('view_page', __name__)
@ -46,7 +48,7 @@ def _load_json(path):
def _load_config(): return _load_json(f'{CONFIGS_DIR}/config.json')
def _load_ddns(): return _load_config().get('ddns', {})
def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
def _load_accounts(): return _load_json(ACCOUNTS_FILE)
def _load_css():
try:
@ -64,6 +66,25 @@ def _load_icon(name):
return ''
def _build_view_map():
m = {}
if not _os.path.isdir(_PAGES_DIR):
return m
for name in _os.listdir(_PAGES_DIR):
cpath = _os.path.join(_PAGES_DIR, name, 'content.json')
if _os.path.isfile(cpath):
try:
with open(cpath) as f:
d = json.load(f)
vid = d.get('id')
if vid:
m[vid] = name
except Exception:
pass
return m
_VIEW_MAP = _build_view_map()
# Shell helper ======================================================
def _run(cmd):
@ -1043,7 +1064,7 @@ def _render_item(item, tokens, inherited_req=None):
extra_cls = (' ' + item['class']) if item.get('class') else ''
return f'<button type="button" class="btn btn-secondary btn-cancel{extra_cls}" disabled>{text}</button>'
if t == 'page_header':
if t == 'header_page_title':
return f'<div class="page-header">{render_items(item.get("items", []), tokens, req)}</div>'
if t in ('section', 'auth_wrapper'):
@ -1192,7 +1213,9 @@ def _render_item(item, tokens, inherited_req=None):
f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>'
if originals else ''
)
return f'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>'
field_specs, submit_sel = _collect_form_specs(item.get('items', []))
script = _render_form_script(field_specs, submit_sel) if (field_specs and submit_sel) else ''
return f'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>{script}'
if t == 'hidden':
name = e(item.get('name', ''))
@ -1331,7 +1354,6 @@ def _render_field(item, tokens):
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
@ -1387,20 +1409,28 @@ def _render_field(item, tokens):
f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}>{e(o["label"])}</option>'
for o in options
)
validate = item.get('validate', '')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
dyn_hint = '<p class="form-hint field-dyn-hint hidden"></p>' if validate else ''
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select{extra_cls}">{opts_html}</select>'
f'{hint_html}</div>'
f'<select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'{hint_html}{dyn_hint}</div>'
)
if input_type == 'number':
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
validate = item.get('validate', 'positive_int')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"'
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
dyn_hint_html = '<p class="form-hint field-dyn-hint hidden"></p>'
inp = (
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr}'
f' class="form-input{extra_cls}"{readonly}'
' data-validate="positive_int" />'
f' class="form-input form-input-mono"{readonly}{validate_attr}{depends_attr}/>'
)
if item.get('layout') == 'inline':
return (
@ -1533,16 +1563,125 @@ def _render_field(item, tokens):
'</div>'
)
validate = item.get('validate', '')
validate = item.get('validate', '')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
dyn_hint = '<p class="form-hint field-dyn-hint hidden"></p>' if (item.get('readonly') or item.get('dyn_hint') or validate) else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
dyn_hint = '<p class="form-hint field-dyn-hint hidden"></p>' if validate else ''
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}{validate_attr}/>{hint_html}{dyn_hint}</div>'
f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}/>'
f'{hint_html}{dyn_hint}</div>'
)
def _collect_form_specs(items):
"""Walk form items; return (field_specs, submit_sel) for factory script generation."""
fields = []
submit_sel = None
for item in items:
t = item.get('type', '')
if t == 'field':
itype = item.get('input_type', 'text')
if item.get('validate') or itype == 'checkbox' or itype == 'number':
fields.append(item)
elif t == 'subnet_row':
fields.append(item)
elif t == 'button_primary' and item.get('class'):
first_cls = item['class'].split()[0]
submit_sel = submit_sel or ('.' + first_cls)
elif t in ('field_row', 'button_row', 'section', 'form'):
sub, sub_btn = _collect_form_specs(item.get('items', []))
fields.extend(sub)
submit_sel = submit_sel or sub_btn
return fields, submit_sel
def _render_form_script(field_specs, submit_sel):
"""Generate an inline <script> for a form's validation and submit-gate wiring."""
import re
_safe = re.compile(r'^[a-zA-Z0-9_-]+$')
lines = ['(function() {']
lines.append(" var _prev = document.currentScript.previousElementSibling;")
lines.append(" var _card = _prev.closest('.card') || _prev.parentElement;")
lines.append(f" var _submit = _card ? _card.querySelector('{submit_sel}') : null;")
lines.append('')
# Classify each spec =================================================
subnet_items = [] # (subnet_var, prefix_var, subnet_name, prefix_name)
validate_items = [] # (js_var, field_name) — validated via validateEl
checkbox_only = [] # js_var — checkboxes that only need change→_upd
gate_vars = [] # JS boolean expressions that must all be true for submit
for spec in field_specs:
t = spec.get('type', '')
if t == 'subnet_row':
sn = spec.get('subnet_name', 'subnet')
pn = spec.get('prefix_name', 'subnet_mask')
if not (_safe.match(sn) and _safe.match(pn)):
continue
sv = '_' + sn.replace('-', '_')
pv = '_' + pn.replace('-', '_')
lines.append(f" var {sv} = _card.querySelector('[name=\"{sn}\"]');")
lines.append(f" var {pv} = _card.querySelector('[name=\"{pn}\"]');")
subnet_items.append((sv, pv))
gate_vars.append(f'{sv} && {sv}._valid')
elif t == 'field':
nm = spec.get('name', '')
itype = spec.get('input_type', 'text')
if not nm or not _safe.match(nm):
continue
vn = '_' + nm.replace('-', '_')
lines.append(f" var {vn} = _card.querySelector('[name=\"{nm}\"]');")
if itype == 'checkbox':
if spec.get('validate'):
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
else:
checkbox_only.append(vn)
else:
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
lines.append('')
# Submit gate =========================================================
gate_expr = ' && '.join(gate_vars) if gate_vars else 'true'
lines.append(' function _upd() {')
lines.append(' if (!_submit) return;')
lines.append(f' _submit.disabled = !({gate_expr});')
lines.append(' }')
lines.append('')
# validateEl listeners ================================================
for vn, _ in validate_items:
lines.append(f" if ({vn}) {vn}.addEventListener('input', function() {{ validateEl({vn}); _upd(); }});")
# subnet_row custom block =============================================
for sv, pv in subnet_items:
lines.append(f' function _chkSubnet() {{')
lines.append(f' if (!{sv} || !{pv}) return;')
lines.append(f" var res = _ipv4SubnetValid({sv}.value.trim(), {pv}.value.trim());")
lines.append(f" setFieldHint({sv}, res.ok ? '' : (res.msg||''), res.ok ? 'ok' : (res.partial ? 'warning' : 'error'));")
lines.append(f' {sv}._valid = res.ok;')
lines.append(f" var dot = {pv}.closest('.form-group').querySelector('.subnet-dotted');")
lines.append(f' var n = parseInt({pv}.value, 10);')
lines.append(f" if (dot) dot.textContent = (!isNaN(n) && n >= 1 && n <= 30) ? prefixToDotted(n) : '';")
lines.append(f' _upd();')
lines.append(f' }}')
lines.append(f" if ({sv}) {sv}.addEventListener('input', _chkSubnet);")
lines.append(f" if ({pv}) {pv}.addEventListener('input', _chkSubnet);")
# Checkbox change → _upd only =========================================
for vn in checkbox_only:
lines.append(f" if ({vn}) {vn}.addEventListener('change', _upd);")
lines.append('}());')
return '<script>' + '\n'.join(lines) + '</script>'
def _collect_form_originals(items, tokens):
"""Walk form items and return {name: value} for all input fields (used for original_values)."""
result = {}
@ -1948,7 +2087,7 @@ def render_layout(view_id, content_html, tokens):
def _render_navbar(active_view, level, tokens, pending_alert=False):
navbar_data = _load_json(f'{DATA_DIR}/navbar_content.json')
navbar_data = _load_json(f'{APP_DIR}/navbar_content.json')
left, right = [], []
for item in navbar_data.get('items', []):
req = item.get('client_requirement')
@ -2029,8 +2168,8 @@ def view(view_id):
return _serve_view(view_id)
def _serve_view(view_id):
content_data = _load_json(f'{DATA_DIR}/page_content.json')
view_def = next((v for v in content_data.get('views', []) if v.get('id') == view_id), None)
page_name = _VIEW_MAP.get(view_id)
view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if page_name else None
if view_def is None:
from flask import abort
@ -2039,7 +2178,7 @@ def _serve_view(view_id):
view_req = view_def.get('client_requirement')
level = _client_level()
if not _passes(view_req, level):
return redirect('/view/view_overview' if level > 0 else '/view/view_log_in')
return redirect('/view/view_overview' if level > 0 else '/view/view_login')
tokens = collect_tokens()