linuxrouter/docker/routlin-dash/app/factory.py
2026-05-31 22:29:05 -04:00

1511 lines
71 KiB
Python

# factory.py — JSON content-type renderer
# Converts content.json item trees into HTML strings.
# Pure type processing: no data loading, no routing, no layout.
from flask import session
from markupsafe import Markup
import json, re, sys, html as html_mod
from config_utils import config_hash
# Injected by view_page at startup ====================================
# view_page sets this after defining load_datasource so that build_table
# can load row data without creating a circular import.
load_datasource = None
# Constants ===========================================================
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
STANDARD_INPUT_TYPES = {'text', 'password', 'number', 'checkbox', 'select', 'textarea'}
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_ENDPOINT': 1 << 12,
'VALIDATION_IPV4_CIDR': 1 << 13,
}
# 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 expand_fields(obj, tokens):
"""Recursively apply token substitution to a field-definition object.
String values that resolve to a JSON array or object are parsed back into
Python structures so they serialize correctly into data-fields JSON."""
if isinstance(obj, list):
return [expand_fields(item, tokens) for item in obj]
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(v, str):
s = apply_tokens(v, tokens)
if s != v and s[:1] in ('[', '{'):
try:
out[k] = json.loads(s)
continue
except Exception:
pass
out[k] = s
else:
out[k] = expand_fields(v, tokens)
return out
return obj
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 _encode_field_validations(fields):
out = []
for f in fields:
f2 = dict(f)
raw = f2.get('validate', '')
if not raw and f2.get('input_type') == 'number':
raw = 'VALIDATION_RANGE_INT'
if raw and isinstance(raw, str):
f2['validate'] = parse_validation(raw)
out.append(f2)
return out
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;
if(validation&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)return t;}
if(validation&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)return t;}
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');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(){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('');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;}
if(validation&8192){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);return(n>=0&&n<=32)?_ok():_err('Prefix must be 0-32');}());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 get_worker_id(datasource):
for prefix in ('config:', 'live:'):
if datasource.startswith(prefix):
return datasource[len(prefix):]
return ''
# 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 == '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(item.get('label', ''))
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 ''
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} 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} 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 ""}>{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 _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 worker script =================================================
def build_table_worker_script(item, expanded_ra_fields):
"""Emit a <script> registering a table worker for any non-standard inline_edit field types.
Returns empty string when all fields are standard types."""
if not expanded_ra_fields:
return ''
worker_id = get_worker_id(item.get('datasource', ''))
if not worker_id:
return ''
nonstandard = set()
for fields in expanded_ra_fields.values():
for f in fields:
it = f.get('input_type', 'text')
if it not in STANDARD_INPUT_TYPES:
nonstandard.add(it)
if not nonstandard:
return ''
if nonstandard == {'credentials'}:
return (
f'<script>registerTableWorker({js_str(worker_id)}, (function() {{\n'
' function _buildCreds(provider, data) {\n'
' if (provider === \'noip\') {\n'
' return \'<div class="cred-field"><span class="cred-label">U:</span>\' +\n'
' \'<input type="text" name="username" value="\' + htmlEsc(data.username||\'\')'
' + \'" class="form-input inline-edit-input"/></div>\' +\n'
' \'<div class="cred-field"><span class="cred-label">P:</span>\' +\n'
' \'<input type="password" name="password" value="\' + htmlEsc(data.password||\'\')'
' + \'" class="form-input inline-edit-input"/></div>\';\n'
' }\n'
' return \'<input type="text" name="api_token" value="\' + htmlEsc(data.api_token||\'\')'
' + \'" class="form-input inline-edit-input" placeholder="API Token"/>\';\n'
' }\n'
' return {\n'
' renderCell: function(fDef, td, val, row) {\n'
' if (fDef.input_type !== \'credentials\') return false;\n'
' td.innerHTML = _buildCreds(row.provider || \'noip\', row);\n'
' return true;\n'
' },\n'
' afterRowOpen: function(tr, row) {\n'
' var provSel = tr.querySelector(\'td[data-field="provider"] select\');\n'
' var credTd = tr.querySelector(\'td[data-field="credentials"]\');\n'
' if (!provSel || !credTd) return;\n'
' provSel.addEventListener(\'change\', function() {\n'
' credTd.innerHTML = _buildCreds(this.value, row);\n'
' });\n'
' }\n'
' };\n'
'}()));</script>\n'
)
return f'<script>registerTableWorker({js_str(worker_id)}, {{}});</script>\n'
# 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':
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 ''
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, inherited_req=None):
level = client_level()
columns = item.get('columns', [])
rows = load_datasource(item.get('datasource', ''))
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>'
expanded_ra_fields = {
i: _encode_field_validations(expand_fields(ra.get('fields', []), tokens))
for i, ra in enumerate(row_actions)
if ra.get('method', 'post').lower() == 'inline_edit'
}
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-edit-mode="reveal"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-target="{target}">{text}</button>'
)
elif method == 'inline_edit':
expanded = expanded_ra_fields.get(ra_i, [])
fields_json = e(json.dumps(expanded))
row_json = e(json.dumps(row))
worker_id = get_worker_id(item.get('datasource', ''))
has_nonstandard = any(
f.get('input_type', 'text') not in STANDARD_INPUT_TYPES
for f in expanded
)
worker_attr = f' data-worker-id="{e(worker_id)}"' if has_nonstandard and worker_id else ''
btns += (
f'<button type="button" class="btn {cls} row-edit-btn"'
f' data-edit-mode="inline"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-action="{action}" data-fields="{fields_json}"{worker_attr}>{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>'
worker_script = build_table_worker_script(item, expanded_ra_fields)
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>{worker_script}'
)
# 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)
return f'<div class="form-row-{cols}">{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':
return build_table(item, tokens, req)
if t == 'raw_html':
return Markup(apply_tokens(item.get('html', ''), tokens))
return ''