linuxrouter/docker/routlin-dash/app/factory.py
2026-06-06 14:57:17 -04:00

1730 lines
81 KiB
Python

# factory.py: HTML renderer and shared utilities
# Builds HTML from content.json item trees. Pure rendering - no data fetching.
from flask import session
from markupsafe import Markup
import json, re, sys, html as html_mod, os, subprocess
from config_utils import (
config_hash, load_config, CONFIGS_DIR, WWW_DIR, APP_DIR,
ACCOUNTS_FILE, HEALTH_FILE, BLOCKLISTS_DIR,
fmt_timestamp, relative_time, fmt_bytes, resolve_iface,
WEB_APP_DISPLAY_NAME,
)
from config_utils import (
get_pending_entries, get_dashboard_pending, _find_cmd_in_queues,
_apply_changes_immediately, _seconds_until_next_run, _format_timing,
_is_locked, _lock_mtime, _entry_ts_from_queue,
)
import settings as settings
PAGES_DIR = os.path.join(APP_DIR, 'pages')
NAVBAR_FILE = os.path.join(APP_DIR, 'navbar.json')
CSS_FILE = os.path.join(WWW_DIR, 'styles.css')
COMMON_JS_FILE = os.path.join(WWW_DIR, 'common.js')
def _file_version(path):
try:
return int(os.path.getmtime(path))
except OSError:
return 0
# Constants ===========================================================
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
VALIDATION_FLAGS = {
'VALIDATION_IPV4_FORMAT': 1 << 0,
'VALIDATION_IPV6_FORMAT': 1 << 1,
'VALIDATION_SUBNET': 1 << 2,
'VALIDATION_ADDRESS': 1 << 3,
'VALIDATION_MAC': 1 << 4,
'VALIDATION_URL': 1 << 5,
'VALIDATION_PORT': 1 << 6,
'VALIDATION_DASH_NAME': 1 << 7,
'VALIDATION_NETWORK_NAME': 1 << 8,
'VALIDATION_DOMAIN_NAME': 1 << 9,
'VALIDATION_TIME24H': 1 << 10,
'VALIDATION_RANGE_INT': 1 << 11,
'VALIDATION_IPV4_CIDR': 1 << 12,
'VALIDATION_IPV4_CIDRFLEX': 1 << 13,
'VALIDATION_UNRESTRICTED': 1 << 14,
'VALIDATION_IP_OR_DOMAIN_NAME': 1 << 15,
}
def _restricted_vlan_subnets():
"""Return list of 'subnet/prefix' strings for all restricted VLANs."""
vlans = load_config().get('vlans', [])
result = []
for v in vlans:
if v.get('restricted_vlan') and v.get('subnet') and v.get('subnet_mask') is not None:
result.append(f"{v['subnet']}/{v['subnet_mask']}")
return result
# File / shell helpers ================================================
def load_json(path):
try:
with open(path) as f:
return json.load(f)
except Exception as ex:
print(f'[factory] ERROR loading {path}: {ex}', file=sys.stderr)
return {}
def load_ddns():
return load_config().get('ddns', {})
def load_accounts():
return load_json(ACCOUNTS_FILE)
def run(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
def load_css():
try:
with open(CSS_FILE) as f:
return f.read()
except Exception:
return ''
def load_icon(name):
try:
with open(f'{WWW_DIR}/icons/{name}.svg') as f:
return f.read().strip()
except Exception:
return ''
def inline_js(page_name=None):
parts = [build_big_validate()]
if not settings.is_production():
try:
with open(COMMON_JS_FILE) as f:
parts.append(f.read())
except Exception:
pass
if page_name:
try:
with open(os.path.join(PAGES_DIR, page_name, 'page.js')) as f:
parts.append(f.read())
except Exception:
pass
return '\n'.join(parts)
# Utilities ===========================================================
def e(text):
return html_mod.escape(str(text))
def _prefix_to_dotted(n):
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
def apply_tokens(text, tokens):
"""Substitute %TOKEN% placeholders. Values are NOT auto-escaped. Callers
that use results in HTML attribute or text context must call e() themselves."""
return re.sub(r'%([A-Z_]+)%', lambda m: str(tokens.get(m.group(1), m.group(0))), text)
def js_str(value):
return json.dumps(str(value))
def parse_validation(s):
if not s:
return 0
result = 0
for token in s.split('|'):
token = token.strip()
val = VALIDATION_FLAGS.get(token)
if val is None:
print(f'[factory] WARNING: unknown validation token "{token}" in "{s}"', file=sys.stderr)
continue
result |= val
return result
def build_big_validate():
body = r"""
function _ok(){return{ok:true,msg:'',partial:false};}
function _par(m){return{ok:false,msg:m||'',partial:true};}
function _err(m){return{ok:false,msg:m||'Invalid',partial:false};}
function _ipv4(s){
if(!s)return'empty';
if(/[^0-9.]/.test(s))return'badchar';
if(/\.\./.test(s)||s[0]==='.')return'badstruct';
var p=s.split('.');
if(p.length>4)return'badstruct';
for(var i=0;i<p.length;i++){if(!p[i])continue;var n=parseInt(p[i],10);if(isNaN(n)||n>255||String(n)!==p[i])return'badrange';}
return(p.length===4&&p.every(function(x){return x!=='';}))? 'ok':'partial';
}
function _ipv6(s){
if(!s)return'empty';
if(/[^0-9a-fA-F:]/.test(s))return'badchar';
if(/:::/.test(s))return'badstruct';
if((s.match(/::/g)||[]).length>1)return'badstruct';
var parts=s.split(/::?/);
for(var i=0;i<parts.length;i++){if(parts[i].length>4)return'badstruct';}
var c=(s.match(/:/g)||[]).length,d=s.indexOf('::')!==-1;
if(d&&c>7)return'badstruct';
return(c===7&&!d)||d?'ok':'partial';
}
function _checkDomain(s){
if(!s)return _par('');
if(/[^a-zA-Z0-9.-]/.test(s))return _err('Letters, digits, hyphens and dots only');
if(s[0]==='.')return _err('Invalid domain format');
if(/\.\./.test(s))return _err('Invalid domain format');
if(s[s.length-1]==='.')return _par('');
var lb=s.split('.');
for(var i=0;i<lb.length;i++){var l=lb[i];if(l[0]==='-'||l[l.length-1]==='-')return _err('Invalid domain format');}
return _ok();
}
function _checkLine(s){
var anyPartial=false,firstMsg='';
function _acc(r){if(r.ok)return r;if(r.partial)anyPartial=true;else if(!firstMsg)firstMsg=r.msg;return null;}
var t;
var fmtMask=validation&3;
if(fmtMask){
var fmtPassed=false;
if(fmtMask&1){t=_acc(function(){var rv=_ipv4(s);if(rv==='ok')return _ok();if(rv==='partial'||rv==='empty')return _par('');if(rv==='badchar')return _err('Invalid character');if(rv==='badrange')return _err('Octet out of range');return _err('Invalid format');}());if(t)fmtPassed=true;}
if(!fmtPassed&&(fmtMask&2)){t=_acc(function(){var rv=_ipv6(s);if(rv==='ok')return _ok();if(rv==='partial'||rv==='empty')return _par('');if(rv==='badchar')return _err('Invalid character');return _err('Invalid format');}());if(t)fmtPassed=true;}
if(!fmtPassed)return anyPartial?_par(''):_err(firstMsg||'Invalid format');
if(!(validation&~3))return _ok();
anyPartial=false;firstMsg='';
}
if(validation&4){t=_acc(function(){if(!arg1)return _par('');var prefix=parseInt(arg1,10);if(isNaN(prefix)||prefix<1||prefix>30)return _err('Prefix must be 1-30');if(!fmtMask){var rv=_ipv4(s);if(rv!=='ok')return(rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):_err('Invalid format'));}var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var ipN=s.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);return((ipN&(~mB>>>0))!==0)?_err('Host bits must be zero'):_ok();}());if(t)return t;}
if(validation&8){t=_acc(function(){if(!fmtMask){var rv=_ipv4(s);if(rv!=='ok')return(rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):_err('Invalid format'));}if(!arg1||!arg2)return _par('');var prefix=parseInt(arg1,10);if(isNaN(prefix)||prefix<1||prefix>30)return _par('');if(_ipv4(arg2)!=='ok')return _par('');var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var snN=arg2.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);if((snN&(~mB>>>0))!==0)return _par('');var iPts=s.split('.').map(Number),sPts=arg2.split('.').map(Number);var ipN=((iPts[0]<<24)|(iPts[1]<<16)|(iPts[2]<<8)|iPts[3])>>>0,sN=((sPts[0]<<24)|(sPts[1]<<16)|(sPts[2]<<8)|sPts[3])>>>0;if((ipN&mB)!==(sN&mB))return _err('IP not in VLAN subnet');var hM=(~mB)>>>0,netN=(sN&mB)>>>0;if(ipN===netN)return _err('Network address not allowed');if(ipN===(netN|hM)>>>0)return _err('Broadcast address not allowed');return _ok();}());if(t)return t;}
if(validation&16){t=_acc(function(){if(!s)return _par('');if(/[^0-9a-fA-F:]/.test(s))return _err('Invalid character');if(/::/.test(s))return _err('Invalid format');var g=s.split(':');if(g.length>6)return _err('Too many groups');for(var i=0;i<g.length;i++){if(g[i].length>2)return _err('Each group must be exactly 2 hex characters');}return(g.length===6&&g.every(function(x){return x.length===2;}))?_ok():_par('');}());if(t)return t;}
if(validation&32){t=_acc(function(){if(!s)return _par('');if(/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s))return _err('Invalid character');var sl=s.toLowerCase();if('https://'.startsWith(sl)||'http://'.startsWith(sl))return _par('');var sep=sl.indexOf('://');if(sep===-1)return _err('Invalid URL format');var scheme=sl.slice(0,sep);if(scheme!=='http'&&scheme!=='https')return _err('Invalid URL format');var after=s.slice(sep+3);if(!after)return _par('');var he=after.search(/[/:?#]/),host=he===-1?after:after.slice(0,he),rest=he===-1?'':after.slice(he);if(!host)return _par('');if(/\.\./.test(host)||host[0]==='.'||host[host.length-1]==='.')return _err('Invalid URL format');var lb=host.split('.');for(var i=0;i<lb.length;i++){if(!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(lb[i]))return _err('Invalid URL format');}if(rest[0]===':'){var pm=rest.slice(1).match(/^\d+/);if(!pm)return _par('');if(parseInt(pm[0])<1||parseInt(pm[0])>65535)return _err('Invalid URL format');}return _ok();}());if(t)return t;}
if(validation&64){t=_acc(function(){if(!s)return _par('');if(/[^0-9]/.test(s))return _err('Digits only');var n=parseInt(s,10);return(n>=1&&n<=65535)?_ok():_err('Must be between 1 and 65535');}());if(t)return t;}
if(validation&128){t=_acc(function(){if(!s)return _par('');if(/[^a-z0-9-]/.test(s))return _err('Lowercase letters, digits and hyphens only');if(s[0]==='-'||/--/.test(s))return _err('No leading, trailing or consecutive hyphens');if(s[s.length-1]==='-')return _par('');return _ok();}());if(t)return t;}
if(validation&256){t=_acc(function(){if(!s)return _par('');if(/[^a-zA-Z0-9_-]/.test(s))return _err('Letters, digits, hyphens and underscores only');if(s[0]==='-'||s[0]==='_')return _err('No leading, trailing or consecutive special characters');if(/[-_]{2,}/.test(s))return _err('No leading, trailing or consecutive special characters');if(s[s.length-1]==='-'||s[s.length-1]==='_')return _par('');return _ok();}());if(t)return t;}
if(validation&512){t=_acc(_checkDomain(s));if(t)return t;}
if(validation&1024){t=_acc(function(){if(!s)return _par('');if(/[^0-9:]/.test(s))return _err('Digits and colon only');if(s.length<5)return _par('');return /^([01]\d|2[0-3]):[0-5]\d$/.test(s)?_ok():_err('Must be HH:MM in 24-hour format (e.g. 02:30)');}());if(t)return t;}
if(validation&2048){t=_acc(function(){if(s===''||s===null||s===undefined)return _par('');if(/[^0-9]/.test(s))return _err('Digits only');var n=parseInt(s,10);var mn=(arg1!==''&&arg1!=null)?parseInt(arg1,10):0;var mx=(arg2!==''&&arg2!=null)?parseInt(arg2,10):null;if(n<mn||(mx!==null&&n>mx)){if(mn!=null&&mx!==null)return _err('Must be between '+mn+' and '+mx);return mn!=null?_err('Must be >= '+mn):_err('Must be <= '+mx);}return _ok();}());if(t)return t;}
if(validation&4096){t=_acc(function(){if(!s)return _par('');var slash=s.indexOf('/');if(slash===-1){var rv=_ipv4(s);return(rv==='ok'||rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_err('Invalid format'));}var rv=_ipv4(s.slice(0,slash));if(rv!=='ok')return rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_par('');var pfx=s.slice(slash+1);if(!pfx)return _par('');if(/[^0-9]/.test(pfx))return _err('Invalid character');var n=parseInt(pfx,10);if(n<0||n>32)return _err('Prefix must be 0-32');var ip=s.slice(0,slash).split('.').map(Number);var ipN=((ip[0]<<24)|(ip[1]<<16)|(ip[2]<<8)|ip[3])>>>0;var mB=n===0?0:((0xFFFFFFFF<<(32-n))>>>0);return((ipN&(~mB>>>0))!==0)?_err('Host bits must be zero'):_ok();}());if(t)return t;}
if(validation&8192){t=_acc(function(){if(!s)return _par('');var slash=s.indexOf('/');if(slash===-1){var rv=_ipv4(s);if(rv==='ok'){var lo=parseInt(s.split('.')[3],10);return lo===0?_par(''):_ok();}return(rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_err('Invalid format'));}var rv=_ipv4(s.slice(0,slash));if(rv!=='ok')return rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_par('');var pfx=s.slice(slash+1);if(!pfx)return _par('');if(/[^0-9]/.test(pfx))return _err('Invalid character');var n=parseInt(pfx,10);if(n<0||n>32)return _err('Prefix must be 0-32');var ip=s.slice(0,slash).split('.').map(Number);var ipN=((ip[0]<<24)|(ip[1]<<16)|(ip[2]<<8)|ip[3])>>>0;var mB=n===0?0:((0xFFFFFFFF<<(32-n))>>>0);return((ipN&(~mB>>>0))!==0)?_err('Host bits must be zero'):_ok();}());if(t)return t;}
if(validation&16384){t=_acc(function(){if(!s)return _par('');var rv=_ipv4(s);if(rv!=='ok')return _par('');if(!collisions||!collisions.length)return _ok();var ip=s.split('.').map(Number);var ipN=((ip[0]<<24)|(ip[1]<<16)|(ip[2]<<8)|ip[3])>>>0;for(var i=0;i<collisions.length;i++){var sp=String(collisions[i]).split('/');if(sp.length!==2)continue;var np=sp[0].split('.').map(Number);if(np.length!==4)continue;var netN=((np[0]<<24)|(np[1]<<16)|(np[2]<<8)|np[3])>>>0;var pfx=parseInt(sp[1],10);var mB=pfx===0?0:((0xFFFFFFFF<<(32-pfx))>>>0);if((ipN&mB)===(netN&mB))return _err('IP is on a restricted VLAN');}return _ok();}());if(t)return t;}
if(validation&32768){t=_acc(function(){if(!s)return _par('');if(/^[0-9.]+$/.test(s)){var rv=_ipv4(s);return rv==='ok'?_ok():(rv==='partial'||rv==='empty')?_par(''):_err('Invalid character');}if(s.indexOf(':')!==-1){var cc=(s.match(/:/g)||[]).length;if(cc>1){if(/:::/.test(s)||(s.match(/::/g)||[]).length>1)return _err('Invalid hostname or IP');if(/[^0-9a-fA-F:.]/.test(s))return _err('Invalid character');var col=s.replace(/[^:]/g,'').length;return(s.indexOf('::')!==-1||col===7)?_ok():_par('');}return _checkDomain(s.slice(0,s.lastIndexOf(':')));}return _checkDomain(s);}());if(t)return t;}
return anyPartial?_par(''):_err(firstMsg||'Invalid');
}
var lines=value.split('\n'),hasPartial=false,seen={},hasContent=false;
for(var i=0;i<lines.length;i++){
var l=lines[i].trim();
if(!l)continue;
hasContent=true;
var r=_checkLine(l);
if(!r.ok&&!r.partial)return r;
if(!r.ok){hasPartial=true;continue;}
if(collisions&&collisions.some(function(c){return String(c)===l;}))return _err('Already in use');
if(dedup){if(seen[l])return _err('Duplicate entry');seen[l]=true;}
}
if(!hasContent)return _par('');
if(hasPartial)return _par('');
return _ok();"""
return f'function bigValidate(value,validation,collisions,dedup,arg1,arg2){{{body}\n}}'
def table_token_key(spec):
return 'TABLE_' + re.sub(r'[^A-Z0-9]', '_', spec.upper())
def iter_table_items(items):
for item in items:
if not isinstance(item, dict):
continue
if item.get('type') == 'table':
yield item
sub_items = item.get('items')
if not isinstance(sub_items, list):
sub_items = []
for sub in (sub_items, (item.get('toolbar') or {}).get('items') or []):
yield from iter_table_items(sub)
# Access control ======================================================
def client_level():
return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
def passes(req, level):
if not req:
return False
for suffix, check in (('+', lambda n, l: l >= n),
('-', lambda n, l: l <= n),
('=', lambda n, l: l == n)):
if req.endswith(suffix):
role = req[:-1].replace('client_is_', '', 1)
needed = LEVEL_RANK.get(role)
if needed is None:
print(f'[factory] WARNING: unknown role "{role}" in client_requirement "{req}"', file=sys.stderr)
return False
return check(needed, level)
print(f'[factory] WARNING: client_requirement "{req}" has no valid suffix (+, -, =)', file=sys.stderr)
return False
# Snapshot helpers ====================================================
def flatten_json(val, prefix):
"""Recursively flatten a parsed JSON value into [(path, leaf_str)] pairs."""
if isinstance(val, dict):
out = []
for k, v in val.items():
out.extend(flatten_json(v, f'{prefix}.{k}'))
return out
if isinstance(val, list):
out = []
for i, v in enumerate(val):
out.extend(flatten_json(v, f'{prefix}[{i}]'))
return out
if val is None:
return [(prefix, None)]
if isinstance(val, bool):
return [(prefix, 'true' if val else 'false')]
return [(prefix, str(val))]
def build_snap_val(changes):
"""Return a brief summary of changed field names for the history table cell."""
if not changes:
return ''
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 snap_expand_row(changes, colspan):
"""Return a hidden <tr> with a per-field change table."""
if not changes:
return ''
rows = ''
for c in changes:
field = c['field']
before_text = c['before']
after_text = c['after']
vtype = c.get('value_type', 'str')
if vtype == 'json':
try:
bval = json.loads(before_text) if before_text is not None else None
aval = json.loads(after_text) if after_text is not None else None
if isinstance(bval, (dict, list)) or isinstance(aval, (dict, list)):
bflat = dict(flatten_json(bval, field)) if isinstance(bval, (dict, list)) else {}
aflat = dict(flatten_json(aval, field)) if isinstance(aval, (dict, list)) else {}
if bflat or aflat:
seen = set()
for k in list(aflat) + list(bflat):
if k in seen:
continue
seen.add(k)
bv = bflat.get(k)
av = aflat.get(k)
rows += (
'<tr>'
f'<td class="snap-expand-field">{e(k)}</td>'
f'<td class="snap-expand-val">{e(bv) if bv is not None else "<em>(none)</em>"}</td>'
f'<td class="snap-expand-val">{e(av) if av is not None else "<em>(none)</em>"}</td>'
'</tr>'
)
continue
except Exception:
pass
bval = before_text if before_text is not None else ''
aval = after_text if after_text is not None else ''
rows += (
'<tr>'
f'<td class="snap-expand-field">{e(field)}</td>'
f'<td class="snap-expand-val">{e(bval) if bval else "<em>(none)</em>"}</td>'
f'<td class="snap-expand-val">{e(aval) if aval else "<em>(none)</em>"}</td>'
'</tr>'
)
inner = (
'<table class="snap-expand-table">'
'<thead><tr>'
'<th class="snap-expand-th">Field</th>'
'<th class="snap-expand-th">Before</th>'
'<th class="snap-expand-th">After</th>'
'</tr></thead>'
f'<tbody>{rows}</tbody>'
'</table>'
)
return f'<tr hidden><td colspan="{colspan}" class="snap-expand-cell">{inner}</td></tr>'
# Form helpers ========================================================
def collect_form_specs(items):
"""Walk form items; return (field_specs, submit_sel) for form 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 build_form_script(field_specs, submit_sel):
"""Generate an inline <script> for a form's validation and submit-gate wiring."""
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('')
subnet_items = []
validate_items = []
checkbox_only = []
gate_vars = []
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))
if spec.get('optional'):
gate_vars.append(f'(!{vn} || !{vn}.value.trim() || {vn}._valid)')
else:
gate_vars.append(f'{vn} && {vn}._valid')
lines.append('')
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('')
for vn, _ in validate_items:
lines.append(f" if ({vn}) {vn}.addEventListener('input', function() {{ validateEl({vn}); _upd(); }});")
for sv, pv in subnet_items:
lines.append(f' function _chkSubnet() {{')
lines.append(f' if (!{sv} || !{pv}) return;')
lines.append(f" var res = bigValidate({sv}.value.trim(), {parse_validation('VALIDATION_SUBNET')}, null, false, {pv}.value.trim(), null);")
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);")
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 fields (used for original_values)."""
result = {}
for item in items:
t = item.get('type', '')
if t == 'field':
name = item.get('name', '')
input_type = item.get('input_type', 'text')
if not name or input_type == 'hidden':
continue
value = apply_tokens(item.get('value', ''), tokens)
if input_type == 'checkbox':
result[name] = '1' if value.lower() in ('true', '1', 'yes') else '0'
elif input_type == 'checkbox_group':
try:
result[name] = json.loads(value) if value else []
except Exception:
result[name] = []
elif input_type == 'select' and not value:
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
value = opts[0]['value'] if opts else ''
except Exception:
pass
result[name] = value
else:
result[name] = value
elif t == 'editable_list':
name = item.get('name', '')
if not name:
continue
try:
vals = json.loads(apply_tokens(item.get('items', '[]'), tokens))
vals = [str(v) for v in vals]
except Exception:
vals = []
result[name] = vals
elif t == 'subnet_row':
result[item.get('subnet_name', 'subnet')] = apply_tokens(item.get('subnet_value', ''), tokens)
result[item.get('prefix_name', 'subnet_mask')] = apply_tokens(item.get('prefix_value', '24'), tokens)
elif t == 'field_row':
result.update(collect_form_originals(item.get('items', []), tokens))
return result
# Table-picker component ==============================================
def build_table_picker(name, label, value, rows, headers, summary_config, action_btn_html=''):
"""Generic table-picker dropdown component.
rows: list of dicts, each with:
key - str: value stored in hidden input and used to identify the row
label - str: text shown in the trigger button
badge_class - str: CSS class for the badge (optional)
badge_label - str: badge text (optional)
cells - list[str]: fully-formed <td>...</td> HTML strings, one per header column
summary - dict[str, str]: field→display-value for the button mini-table (optional)
extra_data - dict[str, str]: additional data-* attrs on the <tr> (optional)
headers: list[str]: column header labels for the dropdown table
summary_config: list of {field, label, mono?} defining the button mini-table columns
action_btn_html: optional extra HTML placed in the picker header (e.g. a Configure button)
"""
rows_html = ''
cur_row = None
for row in rows:
sel_cls = ' selected' if row['key'] == value else ''
if row['key'] == value:
cur_row = row
attrs = f'data-key="{e(row["key"])}" data-label="{e(row["label"])}"'
if row.get('badge_class'):
attrs += f' data-badge-class="{e(row["badge_class"])}" data-badge-label="{e(row.get("badge_label", ""))}"'
for field, val in (row.get('summary') or {}).items():
attrs += f' data-{e(field)}="{e(str(val))}"'
for attr, val in (row.get('extra_data') or {}).items():
attrs += f' data-{e(attr)}="{e(str(val))}"'
cells_html = ''.join(row.get('cells', []))
rows_html += f'<tr class="table-picker-row{sel_cls}" {attrs}>{cells_html}</tr>'
thead = ''.join(f'<th>{e(h)}</th>' for h in headers)
table_html = (
'<div class="table-wrapper">'
'<table class="data-table table-picker-table">'
f'<thead><tr>{thead}</tr></thead>'
f'<tbody>{rows_html}</tbody>'
'</table></div>'
)
btn_label = f'<span class="table-picker-name">{e(value) if value else "Select..."}</span>'
btn_badge = ''
if cur_row and cur_row.get('badge_class'):
btn_badge = (
f'<span class="badge {e(cur_row["badge_class"])} table-picker-badge">'
f'{e(cur_row.get("badge_label", ""))}</span>'
)
ext_meta = ''
if cur_row and summary_config:
summary = cur_row.get('summary') or {}
if any(summary.get(c['field']) for c in summary_config):
hcells = ''.join(f'<th>{e(c["label"])}</th>' for c in summary_config)
def _dcell(c):
cls = ' class="col-mono"' if c.get('mono') else ''
return f'<td{cls}>{e(str(summary.get(c["field"]) or "-"))}</td>'
dcells = ''.join(_dcell(c) for c in summary_config)
ext_meta = (
'<table class="table-picker-stats">'
f'<thead><tr>{hcells}</tr></thead>'
f'<tbody><tr>{dcells}</tr></tbody>'
'</table>'
)
summary_attr = ''
if summary_config:
summary_json = json.dumps([
{k: v for k, v in c.items() if k in ('field', 'label', 'mono')}
for c in summary_config
])
summary_attr = f' data-summary="{e(summary_json)}"'
script = (
'<script>(function(){'
'var _fg=document.currentScript.previousElementSibling;'
'var _pk=_fg.querySelector(\'.table-picker\');'
'var _btn=_pk.querySelector(\'.table-picker-btn\');'
'var _hdr=_pk.querySelector(\'.table-picker-header\');'
'var _dd=_pk.querySelector(\'.table-picker-dropdown\');'
'var _hid=_pk.querySelector(\'input[type="hidden"]\');'
'var _sc=JSON.parse(_pk.dataset.summary||\'[]\');'
'function _apply(key){'
'var row=_dd.querySelector(\'.table-picker-row[data-key="\'+key+\'"]\');'
'if(!row)return;'
'_btn.querySelector(\'.table-picker-name\').textContent=row.dataset.label||key;'
'var badge=_btn.querySelector(\'.table-picker-badge\');'
'if(row.dataset.badgeClass){'
'if(!badge){badge=document.createElement(\'span\');_btn.appendChild(badge);}'
'badge.className=\'badge \'+row.dataset.badgeClass+\' table-picker-badge\';'
'badge.textContent=row.dataset.badgeLabel||\'\';'
'}else if(badge){badge.remove();}'
'if(_sc.length){'
'var stats=_hdr.querySelector(\'.table-picker-stats\');'
'if(!stats){'
'stats=document.createElement(\'table\');'
'stats.className=\'table-picker-stats\';'
'var hc=_sc.map(function(c){return\'<th>\'+htmlEsc(c.label)+\'</th>\';}).join(\'\');'
'stats.innerHTML=\'<thead><tr>\'+hc+\'</tr></thead><tbody><tr></tr></tbody>\';'
'_hdr.appendChild(stats);'
'}'
'var dc=_sc.map(function(c){'
'var v=row.dataset[c.field]!==undefined?row.dataset[c.field]:\'-\';'
'return\'<td\'+(c.mono?\' class="col-mono"\':\'\')+\'>\'+htmlEsc(v)+\'</td>\';'
'}).join(\'\');'
'stats.querySelector(\'tbody tr\').innerHTML=dc;'
'}'
'_dd.querySelectorAll(\'.table-picker-row\').forEach(function(r){'
'r.classList.toggle(\'selected\',r===row);'
'});'
'}'
'_hid.addEventListener(\'change\',function(){_apply(_hid.value);});'
'_btn.addEventListener(\'click\',function(e){'
'e.stopPropagation();'
'var wasOpen=_dd.classList.contains(\'open\');'
'tablePickerCloseAll();'
'if(!wasOpen)_dd.classList.add(\'open\');'
'});'
'_dd.addEventListener(\'click\',function(e){e.stopPropagation();});'
'_dd.querySelectorAll(\'.table-picker-row\').forEach(function(row){'
'row.addEventListener(\'click\',function(){'
'_hid.value=this.dataset.key;'
'tablePickerCloseAll();'
'_hid.dispatchEvent(new Event(\'change\',{bubbles:true}));'
'});'
'});'
'})();</script>'
)
return (
'<div class="form-group">'
f'<label class="form-label">{e(label)}</label>'
f'<div class="table-picker"{summary_attr}>'
f'<input type="hidden" name="{e(name)}" value="{e(value)}"/>'
'<div class="table-picker-header">'
f'<button type="button" class="table-picker-btn">{btn_label}{btn_badge}</button>'
f'{ext_meta}'
f'{action_btn_html}'
'</div>'
f'<div class="table-picker-dropdown">{table_html}</div>'
'</div>'
'</div>'
f'{script}'
)
# Field renderer ======================================================
def build_field(item, tokens):
label = e(apply_tokens(item.get('label', ''), tokens))
name = e(item.get('name', ''))
input_type = item.get('input_type', 'text')
value = apply_tokens(item.get('value', ''), 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 ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
return f'<input type="hidden" name="{name}" value="{e(value)}"/>'
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
disabled_raw = apply_tokens(str(item.get('disabled', '')), tokens)
disabled = ' disabled' if disabled_raw and disabled_raw not in ('false', '0') else ''
cb_label = item.get('checkbox_label')
if cb_label:
label_html = f'<label class="form-label">{label}</label>' if label else ''
return (
'<div class="form-group">'
f'{label_html}'
'<label class="form-checkbox-row">'
f'<input type="checkbox" name="{name}" {checked}{disabled} class="form-checkbox"/>'
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
f'</label>{hint_html}</div>'
)
return (
'<div class="form-group">'
'<label class="form-label">'
f'<input type="checkbox" name="{name}" {checked}{disabled} class="form-checkbox"/> {label}'
f'</label>{hint_html}</div>'
)
if input_type == 'checkbox_group':
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
selected = json.loads(value) if value else []
except Exception:
opts, selected = [], []
boxes = ''.join(
'<label class="checkbox-group-item">'
f'<input type="checkbox" name="{name}" value="{e(o.get("value",""))}"'
f'{"checked" if o.get("value") in selected else ""}/> {e(o.get("label",""))}'
'</label>'
for o in opts
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="checkbox-group">{boxes}</div>{hint_html}</div>'
)
if input_type == 'select':
options = item.get('options', [])
if isinstance(options, str):
try:
options = json.loads(apply_tokens(options, tokens))
except Exception:
options = []
current = apply_tokens(item.get('value', ''), tokens)
opts_html = ''.join(
f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}{" disabled" if o.get("disabled") else ""}>{e(o["label"])}</option>'
for o in options
)
validate_raw = item.get('validate', '')
depends = item.get('depends', [])
_vmask = parse_validation(validate_raw) if validate_raw else 0
validate_attr = f' data-validate="{_vmask}"' if _vmask else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
if _vmask:
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap"><select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'<p class="form-hint field-dyn-hint hidden"></p></div>'
f'{hint_html}</div>'
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'{hint_html}</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 ''
validate_raw = item.get('validate', 'VALIDATION_RANGE_INT')
depends = item.get('depends', [])
existing_ids = apply_tokens(item.get('existing_ids', ''), tokens)
_vmask = parse_validation(validate_raw)
validate_attr = f' data-validate="{_vmask}"'
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
existing_attr = f' data-existing-ids="{e(existing_ids)}"' if existing_ids 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 form-input-mono"{readonly}{validate_attr}{depends_attr}{existing_attr}/>'
)
if item.get('layout') == 'inline':
return (
'<div class="form-group" style="display:flex;align-items:center;gap:0.75em">'
f'<label class="form-label" style="margin:0;white-space:nowrap">{label}</label>'
f'<div class="field-wrap" style="width:6rem">{inp}{dyn_hint_html}</div>'
f'{hint_html}</div>'
)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap">{inp}{dyn_hint_html}</div>{hint_html}</div>'
)
if input_type == 'textarea':
rows = item.get('rows', 4)
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<textarea name="{name}" rows="{rows}" placeholder="{placeholder}"'
f' class="form-input">{e(value)}</textarea>'
f'{hint_html}</div>'
)
if input_type == 'interface_picker':
current = apply_tokens(item.get('value', ''), tokens)
try:
ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens))
except Exception:
ifaces = []
state_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
try:
speed_pad = int(tokens.get('NETWORK_INTERFACE_STATS_SPEED_PAD', '0'))
except Exception:
speed_pad = 0
def _pad(val, width):
s = str(val) if val else '-'
return ' ' * max(0, width - len(s)) + s
picker_rows = []
action_btn_html = ''
for ifc in ifaces:
iname = ifc.get('name', '')
wireless = ifc.get('wireless', False)
state = ifc.get('state', 'UNKNOWN')
carrier = ifc.get('carrier')
raw_speed = ifc.get('speed')
raw_mtu = ifc.get('mtu')
raw_mac = ifc.get('mac')
perm_mac = ifc.get('perm_mac', '')
min_mtu = ifc.get('min_mtu')
max_mtu = ifc.get('max_mtu')
sc, st = state_map.get(state, ('badge-disabled', state.title()))
type_txt = 'Wireless' if wireless else 'Wired'
carrier_txt = '-' if wireless else ('Yes' if carrier else ('No' if carrier is False else '-'))
disp_speed = _pad(raw_speed, speed_pad)
disp_mtu = _pad(raw_mtu, 4)
picker_rows.append({
'key': iname,
'label': iname,
'badge_class': sc,
'badge_label': st,
'cells': [
f'<td class="col-mono">{e(iname)}</td>',
f'<td>{e(type_txt)}</td>',
f'<td><span class="badge {sc}">{st}</span></td>',
f'<td>{e(carrier_txt)}</td>',
f'<td>{e(disp_speed)}</td>',
f'<td>{e(disp_mtu)}</td>',
f'<td class="col-mono">{e(raw_mac or "-")}</td>',
],
'summary': {
'speed': disp_speed,
'mtu': disp_mtu,
'mac': raw_mac or '-',
},
'extra_data': {
'perm-mac': perm_mac,
'min-mtu': str(min_mtu) if min_mtu is not None else '',
'max-mtu': str(max_mtu) if max_mtu is not None else '',
},
})
if iname == current:
action_btn_html = (
'<button type="button" class="btn btn-secondary iface-configure-btn"'
f' data-iface="{e(iname)}" data-mtu="{e(raw_mtu or "")}"'
f' data-mac="{e(raw_mac or "")}" data-perm-mac="{e(perm_mac)}"'
f' data-min-mtu="{str(min_mtu) if min_mtu is not None else ""}"'
f' data-max-mtu="{str(max_mtu) if max_mtu is not None else ""}">'
'Configure</button>'
)
headers = ['Interface', 'Type', 'State', 'Carrier', 'Speed', 'MTU', 'MAC']
summary_config = [
{'field': 'speed', 'label': 'Speed'},
{'field': 'mtu', 'label': 'MTU'},
{'field': 'mac', 'label': 'MAC', 'mono': True},
]
return build_table_picker(name, label, current, picker_rows, headers, summary_config, action_btn_html)
validate_raw = item.get('validate', '')
depends = item.get('depends', [])
_vmask = parse_validation(validate_raw) if validate_raw else 0
validate_attr = f' data-validate="{_vmask}"' if _vmask else ''
depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else ''
extra_attrs = ''.join(f' {e(ak)}="{e(apply_tokens(str(av), tokens))}"' for ak, av in item.get('attrs', {}).items())
optional_attr = ' data-optional="1"' if item.get('optional') else ''
existing_ids = apply_tokens(item.get('existing_ids', ''), tokens)
existing_attr = f' data-existing-ids="{e(existing_ids)}"' if existing_ids else ''
if not existing_ids and (_vmask & VALIDATION_FLAGS.get('VALIDATION_UNRESTRICTED', 0)):
_rsubnets = _restricted_vlan_subnets()
if _rsubnets:
existing_attr = f' data-existing-ids="{e(json.dumps(_rsubnets))}"'
if _vmask:
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="field-wrap"><input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}{extra_attrs}{optional_attr}{existing_attr}/>'
f'<p class="form-hint field-dyn-hint hidden"></p></div>'
f'{hint_html}</div>'
)
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"{readonly}{validate_attr}{depends_attr}{extra_attrs}{optional_attr}{existing_attr}/>'
f'{hint_html}</div>'
)
# Editable list renderer ==============================================
def build_editable_list(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
ph = e(apply_tokens(item.get('item_placeholder', ''), tokens))
add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
validate_raw = item.get('validate', '')
_vmask = parse_validation(validate_raw) if validate_raw else 0
try:
items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens))
except Exception:
items_list = []
rows = ''.join(
'<div class="editable-list-item">'
f'<input type="text" name="{name}" value="{e(v)}" class="form-input"/>'
'<button type="button" class="btn btn-ghost btn-sm editable-list-remove">Remove</button>'
'</div>'
for v in items_list
)
validate_attr = f' data-validate="{_vmask}"' if _vmask else ''
return (
f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}"{validate_attr}>'
f'{rows}'
f'<button type="button" class="btn btn-ghost btn-sm editable-list-add">+ {add_lbl}</button>'
f'</div>{hint_html}</div>'
)
# Table cell renderer =================================================
def build_table_cell(value, render_fn, col_class='', field='', row_idx=None,
toggle_action=None, toggle_allowed=True, render_options=None):
parts = []
if col_class:
parts.append(f'class="{e(col_class)}"')
if field:
parts.append(f'data-field="{e(field)}"')
td_open = f'<td {" ".join(parts)}>' if parts else '<td>'
if not render_fn:
return f'{td_open}{e(value)}</td>'
if render_fn == 'badge_enabled_disabled':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
inner = '<span class="badge badge-enabled">Enabled</span>'
else:
inner = '<span class="badge badge-disabled">Disabled</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_yes_no':
opts = render_options or {}
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
tip = f' data-tooltip="{e(opts["title_true"])}"' if opts.get('title_true') else ''
inner = f'<span class="badge badge-enabled"{tip}>Yes</span>'
else:
tip = f' data-tooltip="{e(opts["title_false"])}"' if opts.get('title_false') else ''
inner = f'<span class="badge badge-disabled"{tip}>No</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_recording_on_off':
if str(value).lower() in ('true', '1', 'yes'):
inner = '<span class="badge badge-enabled">Recording On</span>'
else:
inner = '<span class="badge badge-disabled">Recording Off</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_toggle':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
label = 'Enabled'; badge_cls = 'badge-enabled'
else:
label = 'Disabled'; badge_cls = 'badge-disabled'
if toggle_action and row_idx is not None and toggle_allowed:
inner = (
f'<form method="post" action="{e(toggle_action)}" class="form-inline">'
f'<input type="hidden" name="row_index" value="{row_idx}"/>'
'<button type="submit" class="btn-badge">'
f'<span class="badge {badge_cls}">{label}</span></button></form>'
)
else:
inner = f'<span class="badge {badge_cls}">{label}</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_active_inactive':
badges = {'active': 'badge-enabled', 'pending': 'badge-warning'}
cls = badges.get(value.lower(), 'badge-disabled')
return f'{td_open}<span class="badge {cls}">{e(value.title())}</span></td>'
if render_fn == 'raw_html':
return f'{td_open}{value}</td>'
if render_fn == 'tag_list':
opts = render_options or {}
prefer_short = opts.get('prefer_short', False)
try:
items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')]
except Exception:
items = [value]
def _tag(t):
if isinstance(t, dict):
s, tooltip = str(t.get('n', '')).strip(), str(t.get('d', t.get('n', ''))).strip()
short = str(t['short']).strip() if 'short' in t else s.split('-')[0]
mini = str(t['mini']).strip() if 'mini' in t else (s[0] if s else '')
else:
s = tooltip = str(t).strip()
short = s.split('-')[0]
mini = s[0] if s else ''
if not s:
return ''
if prefer_short:
return f'<span class="tag" data-tooltip="{e(tooltip)}">{e(short)}</span>'
return (
f'<span class="tag" data-tooltip="{e(tooltip)}">'
f'<span class="tl-full">{e(s)}</span>'
f'<span class="tl-short">{e(short)}</span>'
f'<span class="tl-min">{e(mini)}</span>'
'</span>'
)
tags = ''.join(_tag(t) for t in items)
return f'{td_open}<div class="tag-list">{tags}</div></td>'
if render_fn == 'interface_status':
v = value.upper()
if v == 'INVALID':
inner = '<span class="badge badge-danger">Invalid</span>'
elif v == 'UP':
inner = '<span class="badge badge-enabled">Up</span>'
elif v == 'DOWN':
inner = '<span class="badge badge-warning">Down</span>'
else:
inner = f'<span class="badge badge-disabled">{e(value.title())}</span>'
return f'{td_open}{inner}</td>'
return f'{td_open}{e(value)}</td>'
# Table renderer ======================================================
def build_table(item, tokens, rows, inherited_req=None):
level = client_level()
columns = item.get('columns', [])
empty = e(item.get('empty_message', 'No data.'))
row_actions = item.get('row_actions', [])
hash_val = config_hash()
toolbar_html = ''
toolbar = item.get('toolbar')
if toolbar:
req = toolbar.get('client_requirement', inherited_req)
if passes(req, level):
t_inner = build_items(toolbar.get('items', []), tokens, req)
toolbar_html = f'<div class="table-toolbar">{t_inner}</div>'
thead = ''.join(
f'<th class="{e(c["class"])}">{e(c.get("label",""))}</th>' if c.get("class") else f'<th>{e(c.get("label",""))}</th>'
for c in columns
)
if row_actions:
thead += '<th></th>'
if not rows:
colspan = len(columns) + (1 if row_actions else 0)
tbody = f'<tr><td colspan="{colspan}" class="table-empty">{empty}</td></tr>'
else:
tbody = ''
for idx, row in enumerate(rows):
cells = ''
for col in columns:
val = row
for part in col.get('field', '').split('.'):
val = val.get(part, '') if isinstance(val, dict) else ''
col_req = col.get('client_requirement', inherited_req)
toggle_allowed = passes(col_req, level) if col_req else True
cells += build_table_cell(
str(val) if val != '' else '-',
col.get('render', ''),
col.get('class', ''),
field=col.get('field', ''),
row_idx=idx,
toggle_action=col.get('toggle_action'),
toggle_allowed=toggle_allowed,
render_options=col.get('render_options', {}),
)
if row_actions:
btns = ''
for ra_i, ra in enumerate(row_actions):
req = ra.get('client_requirement', inherited_req)
if not passes(req, level):
continue
text = e(ra.get('text', ''))
cls = e(ra.get('class', 'btn-ghost btn-sm'))
action = e(apply_tokens(ra.get('action', '#'), tokens))
method = ra.get('method', 'post').lower()
if method == 'post':
disable_if = ra.get('disable_if')
if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'):
btns += f'<button type="button" class="btn {cls}" disabled>{text}</button>'
continue
btns += (
f'<form method="post" action="{action}" class="form-inline">'
f'<input type="hidden" name="row_index" value="{idx}"/>'
f'<input type="hidden" name="config_hash" value="{e(hash_val)}"/>'
f'<button type="submit" class="btn {cls}">{text}</button></form>'
)
elif method == 'js_edit':
target = e(ra.get('target', 'edit-form'))
row_json = e(json.dumps(row))
btns += (
f'<button type="button" class="btn {cls} row-edit-btn"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-target="{target}">{text}</button>'
)
else:
btns += f'<a href="{action}?row_index={idx}" class="btn {cls}">{text}</a>'
cells += f'<td class="col-actions">{btns}</td>'
tbody += f'<tr>{cells}</tr>'
return (
f'{toolbar_html}'
'<div class="table-wrapper">'
'<table class="data-table">'
f'<thead><tr>{thead}</tr></thead>'
f'<tbody>{tbody}</tbody>'
f'</table></div>'
)
# Main dispatcher =====================================================
def build_items(items, tokens, inherited_req=None):
level = client_level()
parts = []
for item in items:
req = item.get('client_requirement', inherited_req)
if not passes(req, level):
continue
parts.append(build_item(item, tokens, req))
return ''.join(parts)
def build_item(item, tokens, inherited_req=None):
t = item.get('type', '')
req = item.get('client_requirement', inherited_req)
if t == 'h1':
return f'<h1>{e(apply_tokens(item.get("text", ""), tokens))}</h1>'
if t == 'hr':
return '<hr class="divider"/>'
if t == 'p':
text = e(apply_tokens(item.get('text', ''), tokens))
link = item.get('link')
if link:
href = e(apply_tokens(link.get('action', '#'), tokens))
ltext = e(apply_tokens(link.get('text', ''), tokens))
return f'<p>{text} <a href="{href}" class="auth-link">{ltext}</a></p>'
return f'<p>{text}</p>'
if t == 'spacer':
return '<div class="spacer"></div>'
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
cls_map = {
'button_primary': 'btn-primary',
'button_secondary': 'btn-secondary',
'button_danger': 'btn-danger',
'button_ghost': 'btn-ghost',
}
cls = cls_map[t]
extra = item.get('class', '')
if extra:
cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens))
action_raw = item.get('action', '')
action = e(apply_tokens(action_raw, tokens))
disabled_val = apply_tokens(str(item.get('disabled', '')), tokens)
disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else ''
formaction = item.get('formaction', '')
if formaction:
formaction = e(apply_tokens(formaction, tokens))
return f'<button type="submit" class="btn {e(cls)}" formaction="{formaction}"{disabled}>{text}</button>'
if item.get('method', '').lower() == 'post':
return (
f'<form method="post" action="{action}" class="form-inline">'
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>'
)
if action_raw:
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
return f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button>'
if t == 'button_cancel':
text = e(apply_tokens(item.get('text', 'Cancel'), tokens))
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 == 'header_page_title':
return f'<div class="page-header">{build_items(item.get("items", []), tokens, req)}</div>'
if t in ('section', 'auth_wrapper'):
tag = 'div'
cls = 'auth-wrapper' if t == 'auth_wrapper' else 'section'
return f'<{tag} class="{cls}">{build_items(item.get("items", []), tokens, req)}</{tag}>'
if t == 'auth_card':
return f'<div class="auth-card">{build_items(item.get("items", []), tokens, req)}</div>'
if t == 'stat_card_grid':
return f'<div class="stat-card-grid">{build_items(item.get("items", []), tokens, req)}</div>'
if t == 'stat_card':
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', '')
edit_input_type = item.get('edit_input_type', 'text')
edit_suffix = item.get('edit_suffix', '')
edit_min = item.get('edit_min', '')
edit_raw = apply_tokens(item.get('edit_value', item.get('value', '')), tokens)
reveal_card_id = item.get('reveal_card_id', '')
if reveal_card_id:
return (
f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
'<div class="stat-card-value-row">'
f'<span class="stat-card-value">{value}</span>'
'<button type="button" class="btn btn-ghost btn-sm"'
f' data-reveal-card="{e(reveal_card_id)}">Edit</button>'
'</div>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
if edit_action and edit_field:
min_attr = f' min="{e(edit_min)}"' if edit_min else ''
suffix_html = f'<span>{e(edit_suffix)}</span>' if edit_suffix else ''
input_wrap = (
'<div class="stat-card-value-row">'
f'<input type="{e(edit_input_type)}" name="{e(edit_field)}" value="{e(edit_raw)}"'
f' data-original="{e(edit_raw)}" class="form-input stat-card-edit-input"{min_attr}/>'
f'{suffix_html}</div>'
)
return (
f'<div class="{cls} stat-card-editable">'
f'<div class="stat-card-label">{label}</div>'
'<div class="stat-card-view">'
f'<span class="stat-card-value">{value}</span>'
'<button type="button" class="btn btn-ghost btn-sm stat-card-edit-btn">Edit</button>'
'</div>'
f'<form class="stat-card-edit-form hidden" action="{e(edit_action)}" method="post">'
f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
f'{input_wrap}'
'<div class="stat-card-edit-actions">'
'<button type="submit" class="btn btn-primary btn-sm" disabled>Save</button>'
'<button type="button" class="btn btn-secondary btn-sm stat-card-cancel-btn">Cancel</button>'
'</div>'
'</form>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
return (
f'<div class="{cls}">'
f'<div class="stat-card-label">{label}</div>'
f'<div class="stat-card-value">{value}</div>'
f'<div class="stat-card-sub">{sub}</div>'
'</div>'
)
if t == 'card':
label = item.get('label', '')
id_attr = f' id="{e(item["id"])}"' if item.get('id') else ''
cls_hidden = ' hidden' if item.get('hidden') else ''
header = f'<div class="card-header"><h2 class="card-title">{e(label)}</h2></div>' if label else ''
body = build_items(item.get('items', []), tokens, req)
return f'<div class="card{cls_hidden}"{id_attr}>{header}<div class="card-body">{body}</div></div>'
if t == 'field_status':
label = e(item.get('label', ''))
raw = apply_tokens(item.get('value', ''), tokens).upper()
badge_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown'))
return (
'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<div class="field-status-badge"><span class="badge {badge_cls}">{badge_text}</span></div>'
'</div>'
)
if t == 'info_bar':
variant = item.get('variant', 'info')
text = e(apply_tokens(item.get('text', ''), tokens))
return f'<div class="info-bar info-bar-inline info-bar-{e(variant)}">{text}</div>'
if t == 'pre_block':
text = e(apply_tokens(item.get('text', ''), tokens))
extra = ' data-scroll-bottom' if item.get('scroll_to_bottom') else ''
return f'<pre class="pre-block"{extra}>{text}</pre>'
if t == 'credential_fields':
psel = e(item.get('provider_select', 'provider'))
return (
f'<div class="credential-fields" data-provider-select="{psel}">'
'<div class="cred-group-token hidden">'
'<div class="form-group"><label class="form-label">API Token</label>'
'<input type="text" name="api_token" class="form-input"/></div>'
'</div>'
'<div class="cred-group-noip hidden">'
'<div class="form-group"><label class="form-label">Username</label>'
'<input type="text" name="username" class="form-input"/></div>'
'<div class="form-group"><label class="form-label">Password</label>'
'<input type="password" name="password" class="form-input"/></div>'
'</div>'
'</div>'
)
if t == 'grid':
rows_html = ''
for row in item.get('rows', []):
cells = ''.join(build_item(c, tokens, req) for c in row.get('cells', []))
rows_html += f'<div class="info-grid-row">{cells}</div>'
return f'<div class="info-grid">{rows_html}</div>'
if t == 'grid_label':
return f'<div class="info-grid-label">{e(apply_tokens(item.get("text", ""), tokens))}</div>'
if t == 'grid_value':
return f'<div class="info-grid-value">{e(apply_tokens(item.get("text", ""), tokens))}</div>'
if t == 'form':
action = e(apply_tokens(item.get('action', ''), tokens))
method = e(item.get('method', 'post'))
inner = build_items(item.get('items', []), tokens, req)
hash_field = f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
originals = collect_form_originals(item.get('items', []), tokens)
orig_field = (
f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>'
if originals else ''
)
field_specs, submit_sel = collect_form_specs(item.get('items', []))
script = build_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', ''))
value = e(apply_tokens(item.get('value', ''), tokens))
return f'<input type="hidden" name="{name}" value="{value}"/>'
if t == 'record_editor':
label = e(item.get('label', ''))
name = e(item.get('name', ''))
empty = e(item.get('empty_message', 'No records added.'))
fields = item.get('fields', [])
col_count = len(fields) + 1
ths = ''.join(f'<th>{e(f.get("label",""))}</th>' for f in fields) + '<th></th>'
form_rows = ''
for f in fields:
f_label = e(f.get('label', ''))
f_name = e(f.get('name', ''))
f_placeholder = e(f.get('placeholder', ''))
f_required = 'true' if f.get('required') else 'false'
f_validate_raw = f.get('validate', '') or f.get('valtype', '')
f_attrs = f.get('attrs', {})
_vmask = parse_validation(f_validate_raw) if f_validate_raw else 0
attr_str = f' data-field="{f_name}" data-required="{f_required}"'
if _vmask:
attr_str += f' data-validate="{_vmask}"'
for ak, av in f_attrs.items():
attr_str += f' {e(ak)}="{e(str(av))}"'
inp = f'<input type="text" class="form-input"{attr_str} placeholder="{f_placeholder}"/>'
if _vmask:
field_inner = (
'<div class="field-wrap">'
+ inp +
'<p class="form-hint field-dyn-hint hidden"></p>'
'</div>'
)
else:
field_inner = inp
form_rows += (
f'<tr>'
f'<td class="record-editor-field-label">{f_label}:</td>'
f'<td>{field_inner}</td>'
f'</tr>'
)
return (
f'<div class="form-group record-editor" data-name="{name}" data-empty-message="{empty}">'
f'<div class="record-editor-body">'
f'<div class="record-editor-table-wrap">'
f'<label class="form-label record-editor-label">{label}</label>'
f'<table class="data-table record-editor-table">'
f'<thead><tr>{ths}</tr></thead>'
f'<tbody class="record-editor-rows">'
f'<tr class="record-editor-empty-row">'
f'<td colspan="{col_count}" class="table-empty">{empty}</td>'
f'</tr>'
f'</tbody>'
f'</table>'
f'</div>'
f'<div class="record-editor-form">'
f'<table class="record-editor-fields-table"><tbody>{form_rows}</tbody></table>'
f'<div style="margin-top:0.5rem">'
f'<button type="button" class="btn btn-secondary btn-sm record-editor-add-btn">Add</button>'
f'<button type="button" class="btn btn-ghost btn-sm record-editor-cancel-btn hidden" style="margin-left:0.5rem">Cancel</button>'
f'</div>'
f'</div>'
f'</div>'
f'<input type="hidden" name="{name}" class="record-editor-hidden" value="[]"/>'
f'</div>'
)
if t == 'readonly_select':
label = e(item.get('label', 'Gateway'))
name = e(item.get('name', 'gateway'))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
return (
f'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select readonly-select" disabled>'
f'<option value="">(add identities first)</option>'
f'</select>'
f'{hint_html}'
f'</div>'
)
if t == 'overridable_textarea':
label = e(item.get('label', ''))
name = e(item.get('name', ''))
override_name = e(item.get('override_name', name + '_override'))
validate_raw = item.get('validate', '')
_vmask = parse_validation(validate_raw) if validate_raw else 0
validate_attr = f' data-validate-lines="{_vmask}"' if _vmask else ''
dyn_hint_html = '<p class="form-hint field-dyn-hint hidden"></p>' if _vmask else ''
wrap_open = '<div class="field-wrap">' if _vmask else ''
wrap_close = '</div>' if _vmask else ''
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
return (
f'<div class="form-group">'
f'<label class="form-label override-header">'
f'<span>{label}</span>'
f'<label class="override-toggle">'
f'<input type="checkbox" name="{override_name}" class="form-checkbox override-check"/> Override'
f'</label>'
f'</label>'
f'{wrap_open}'
f'<textarea name="{name}" class="form-input auto-textarea" rows="2" readonly{validate_attr}></textarea>'
f'{dyn_hint_html}'
f'{wrap_close}'
f'{hint_html}'
f'</div>'
)
if t == 'field':
return build_field(item, tokens)
if t == 'field_row':
inner = build_items(item.get('items', []), tokens, req)
cols = item.get('cols', 2)
style_attr = f' style="{e(item["style"])}"' if item.get('style') else ''
return f'<div class="form-row-{cols}"{style_attr}>{inner}</div>'
if t == 'subnet_row':
subnet_label = e(item.get('label', 'Subnet'))
subnet_name = e(item.get('subnet_name', 'subnet'))
prefix_name = e(item.get('prefix_name', 'subnet_mask'))
subnet_val = apply_tokens(item.get('subnet_value', ''), tokens)
prefix_raw = apply_tokens(item.get('prefix_value', '24'), tokens)
subnet_ph = e(apply_tokens(item.get('subnet_placeholder', ''), tokens))
try:
pf = max(1, min(30, int(prefix_raw)))
except (ValueError, TypeError):
pf = 24
dotted = _prefix_to_dotted(pf)
return (
'<div class="form-group">'
f'<label class="form-label">{subnet_label}</label>'
'<div class="field-wrap">'
'<div class="subnet-row-wrap">'
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input"/>'
'<span class="subnet-sep">/</span>'
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input"/>'
'</div>'
f'<span class="subnet-dotted">{e(dotted)}</span>'
'<p class="form-hint field-dyn-hint hidden"></p>'
'</div>'
'</div>'
)
if t == 'editable_list':
return build_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
filter_col = item.get('filter_col', '')
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
if t == 'spacer':
return '<span style="margin-left:auto"></span>'
if t == 'button_row':
justify = item.get('justify', '')
style_attr = f' style="justify-content:{e(justify)}"' if justify else ''
inner = build_items(item.get('items', []), tokens, req)
return f'<div class="button-row"{style_attr}>{inner}</div>'
if t == 'table':
ds = item.get('datasource', '')
key = table_token_key(ds)
if key in tokens:
return Markup(tokens[key])
return build_table(item, tokens, [], req)
if t == 'raw_html':
return Markup(apply_tokens(item.get('html', ''), tokens))
return ''
# Layout renderer =====================================================
def render_layout(view_id, content_html, tokens, page_name=None):
level = client_level()
has_pending_alert = not _apply_changes_immediately() and bool(get_dashboard_pending())
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{WEB_APP_DISPLAY_NAME}</span></div>'
navbar_html = build_navbar(view_id, level, tokens, pending_alert=has_pending_alert)
footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
page_hash = config_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
current_user = session.get('email_address', '')
pending = get_pending_entries()
my_uuid = next((u for u, t, c, usr in pending if usr == current_user and c != 'fix problems'), None)
secs = _seconds_until_next_run()
locked = _is_locked()
lock_mtime = _lock_mtime()
other_bars = ''
seen_other_users = set()
for o_uuid, o_ts, o_cmd, o_user in pending:
if o_user == current_user:
continue
if o_user in seen_other_users:
continue
seen_other_users.add(o_user)
display_user = 'Another user' if o_user in ('unknown', '') else e(o_user)
if locked and lock_mtime and o_ts < lock_mtime:
text = f'{display_user}\'s changes are being applied now...'
cls = 'info-bar-warning info-bar-running'
else:
timing = _format_timing(secs)
text = (
f'{display_user} has pending changes which will be applied {timing}.'
if timing else
f'{display_user} has pending changes. The processing service is not running.'
)
cls = 'info-bar-warning'
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}"><span>{text}</span></div>\n'
problem_bars = ''
if level >= LEVEL_RANK['viewer']:
try:
st = json.load(open(HEALTH_FILE))
problems = []
for section in ('configurations', 'logs'):
for item in st.get(section, []):
if item.get('status') == 'problem':
problems.append(e(item.get('detail', item.get('name', ''))))
for item in st.get('services', []):
if item.get('status') == 'problem':
name = item.get('name', '')
utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit'
exp_parts, act_parts = [], []
if not item.get('active_ok'):
exp_parts.append(item.get('expected_active', 'active'))
act_parts.append(item.get('active', 'unknown'))
if not item.get('enabled_ok'):
exp_parts.append(item.get('expected_enabled', 'enabled'))
act_parts.append(item.get('enabled', 'unknown'))
problems.append(e(
f"The {utype} `{name}` is expected to be "
f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}."
))
has_problems = bool(problems)
fix_suffix = ''
fix_uuid = None
if has_problems:
if level < LEVEL_RANK['administrator']:
fix_suffix = 'Please contact an administrator.'
else:
fix_uuid, fix_ts = _find_cmd_in_queues('fix problems')
if _apply_changes_immediately():
if _is_locked():
mtime = _lock_mtime()
fix_suffix = (
'Fix is being applied now...'
if fix_ts and mtime and fix_ts < mtime
else 'Fix will be applied on the next run.'
)
else:
timing = _format_timing(_seconds_until_next_run())
fix_suffix = (
f'Fix will be applied {timing}.'
if timing else
'Fix pending. The processing service is not running.'
)
else:
fix_suffix = (
'Fix pending. Click <strong>Apply Now</strong> below to fix.'
if view_id == 'actions' else
'Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix.'
)
if problems:
problems_list = (
'<ul style="margin:0.25em 0;padding-left:1.25em">'
+ ''.join(f'<li>{d}</li>' for d in problems)
+ '</ul>'
)
uuid_attr = (
f' data-health-uuid="{e(fix_uuid)}"'
if fix_uuid and _entry_ts_from_queue(fix_uuid) is not None else ''
)
fix_html = (
f'<div style="margin-top:0.5em"{uuid_attr}>{fix_suffix}</div>'
if fix_suffix else ''
)
content = (
'<div style="width:100%">'
'<div style="font-weight:600;margin-bottom:0.25em">Health check - problems found:</div>'
+ problems_list + fix_html
+ '</div>'
)
problem_bars += f'<div class="info-bar info-bar-danger">{content}</div>\n'
except Exception:
pass
pending_bar = ''
if has_pending_alert and not problem_bars and view_id != 'actions':
pending_bar = (
'<div class="info-bar info-bar-warning">'
'<span>You have actions pending. Please visit the <strong>Actions</strong> page.</span>'
'</div>\n'
)
if settings.is_production():
css_ver = _file_version(CSS_FILE)
js_ver = _file_version(COMMON_JS_FILE)
css_tag = f' <link rel="stylesheet" href="/www/styles.css?v={css_ver}">\n'
common_js = f'<script src="/www/common.js?v={js_ver}"></script>\n'
else:
css_tag = f' <style>{load_css()}</style>\n'
common_js = ''
return (
'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
' <meta charset="UTF-8"/>\n'
' <meta name="viewport" content="width=device-width, initial-scale=1.0"/>\n'
f' <title>{WEB_APP_DISPLAY_NAME}</title>\n'
f'{css_tag}'
'</head>\n<body>\n'
f'{titlebar_html}\n'
f'{navbar_html}\n'
f'<main class="main-content">\n{pending_bar}{problem_bars}{other_bars}{content_html}\n</main>\n'
f'{footer_html}\n'
f'{common_js}'
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var APPLY_UUID={json.dumps(my_uuid)};</script>\n'
f'<script>{inline_js(page_name)}</script>\n'
'</body>\n</html>'
)
def build_navbar(active_view, level, tokens, pending_alert=False):
navbar_data = load_json(NAVBAR_FILE)
left, right = [], []
for item in navbar_data.get('items', []):
req = item.get('client_requirement')
align = item.get('align', 'left')
if not passes(req, level):
continue
frag = build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req, pending_alert=pending_alert)
(right if align == 'right' else left).append(frag)
return (
'<nav class="nav-bar">'
f'<div class="nav-left">{"".join(left)}</div>'
f'<div class="nav-right">{"".join(right)}</div>'
'</nav>'
)
def build_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None, pending_alert=False):
req = item.get('client_requirement', inherited_req)
t = item.get('type', '')
if t in ('nav_item', 'nav_action'):
label = e(item.get('label', ''))
map_to = item.get('map_to', '')
action = item.get('action', '')
is_active = ' active' if map_to and map_to == active_view else ''
pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}'
if action:
return (
f'<form method="post" action="/action/{e(action)}" class="form-inline">'
f'<button type="submit" class="{cls}">{label}</button></form>'
)
if map_to:
return f'<a href="/{e(map_to)}" class="{cls}">{label}</a>'
return f'<span class="{cls}">{label}</span>'
if t == 'nav_menu':
raw_label = item.get('label', '')
if raw_label == '%MENU_LABEL%':
raw_label = 'Network'
label = e(raw_label)
children = ''
for child in item.get('items', []):
child_req = child.get('client_requirement', req)
if not passes(child_req, level):
continue
children += build_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req, pending_alert=pending_alert)
if not children:
return ''
return (
'<div class="nav-menu">'
f'<button class="nav-item nav-menu-trigger" aria-haspopup="true">{label}</button>'
f'<div class="nav-dropdown">{children}</div>'
'</div>'
)
return ''