# 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;i255||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;i4)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;i30)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;i2)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;i65535)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(nmx)){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= 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 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 += ( '' f'{e(k)}' f'{e(bv) if bv is not None else "(none)"}' f'{e(av) if av is not None else "(none)"}' '' ) 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 += ( '' f'{e(field)}' f'{e(bval) if bval else "(none)"}' f'{e(aval) if aval else "(none)"}' '' ) inner = ( '' '' '' '' '' '' f'{rows}' '
FieldBeforeAfter
' ) return f'{inner}' # 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 ' 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 ... 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 (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'{cells_html}' thead = ''.join(f'{e(h)}' for h in headers) table_html = ( '
' '' f'{thead}' f'{rows_html}' '
' ) btn_label = f'{e(value) if value else "Select..."}' btn_badge = '' if cur_row and cur_row.get('badge_class'): btn_badge = ( f'' f'{e(cur_row.get("badge_label", ""))}' ) 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'{e(c["label"])}' for c in summary_config) def _dcell(c): cls = ' class="col-mono"' if c.get('mono') else '' return f'{e(str(summary.get(c["field"]) or "-"))}' dcells = ''.join(_dcell(c) for c in summary_config) ext_meta = ( '' f'{hcells}' f'{dcells}' '
' ) 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 = ( '' ) return ( '
' f'' f'
' f'' '
' f'' f'{ext_meta}' f'{action_btn_html}' '
' f'
{table_html}
' '
' '
' 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'

{hint}

' if hint else '' readonly = ' readonly' if item.get('readonly') else '' if input_type == 'hidden': return f'' 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'' if label else '' return ( '
' f'{label_html}' '{hint_html}
' ) return ( '
' '{hint_html}
' ) 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( '' for o in opts ) return ( f'
' f'
{boxes}
{hint_html}
' ) 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'' 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'
' f'
' f'
' f'{hint_html}
' ) return ( f'
' f'' f'{hint_html}
' ) 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 = '' inp = ( f'' ) if item.get('layout') == 'inline': return ( '
' f'' f'
{inp}{dyn_hint_html}
' f'{hint_html}
' ) return ( f'
' f'
{inp}{dyn_hint_html}
{hint_html}
' ) if input_type == 'textarea': rows = item.get('rows', 4) return ( f'
' f'' f'{hint_html}
' ) 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'{e(iname)}', f'{e(type_txt)}', f'{st}', f'{e(carrier_txt)}', f'{e(disp_speed)}', f'{e(disp_mtu)}', f'{e(raw_mac or "-")}', ], '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 = ( '' ) 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'
' f'
' f'
' f'{hint_html}
' ) return ( f'
' f'' f'{hint_html}
' ) # 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'

{hint}

' 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( '
' f'' '' '
' for v in items_list ) validate_attr = f' data-validate="{_vmask}"' if _vmask else '' return ( f'
' f'
' f'{rows}' f'' f'
{hint_html}
' ) # Table worker script ================================================= def build_table_worker_script(item, expanded_ra_fields): """Emit a \n' ) return f'\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'' if parts else '' if not render_fn: return f'{td_open}{e(value)}' if render_fn == 'badge_enabled_disabled': if str(value).lower() in ('true', '1', 'yes', 'enabled'): inner = 'Enabled' else: inner = 'Disabled' return f'{td_open}{inner}' 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'Yes' else: tip = f' data-tooltip="{e(opts["title_false"])}"' if opts.get('title_false') else '' inner = f'No' return f'{td_open}{inner}' if render_fn == 'badge_recording_on_off': if str(value).lower() in ('true', '1', 'yes'): inner = 'Recording On' else: inner = 'Recording Off' return f'{td_open}{inner}' 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'
' f'' '
' ) else: inner = f'{label}' return f'{td_open}{inner}' if render_fn == 'badge_active_inactive': badges = {'active': 'badge-enabled', 'pending': 'badge-warning'} cls = badges.get(value.lower(), 'badge-disabled') return f'{td_open}{e(value.title())}' if render_fn == 'raw_html': return f'{td_open}{value}' 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'' f'{e(s)}' f'{e(short)}' f'{e(mini)}' '' ) tags = ''.join(_tag(t) for t in items) return f'{td_open}
{tags}
' if render_fn == 'interface_status': v = value.upper() if v == 'INVALID': inner = 'Invalid' elif v == 'UP': inner = 'Up' elif v == 'DOWN': inner = 'Down' else: inner = f'{e(value.title())}' return f'{td_open}{inner}' return f'{td_open}{e(value)}' # 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'
{t_inner}
' thead = ''.join( f'{e(c.get("label",""))}' if c.get("class") else f'{e(c.get("label",""))}' for c in columns ) if row_actions: thead += '' 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'{empty}' 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'' continue btns += ( f'
' f'' f'' f'
' ) elif method == 'js_edit': target = e(ra.get('target', 'edit-form')) row_json = e(json.dumps(row)) btns += ( f'' ) 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'' ) else: btns += f'{text}' cells += f'{btns}' tbody += f'{cells}' worker_script = build_table_worker_script(item, expanded_ra_fields) return ( f'{toolbar_html}' '
' '' f'{thead}' f'{tbody}' f'
{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'

{e(apply_tokens(item.get("text", ""), tokens))}

' if t == 'hr': return '
' if t == 'p': text = e(apply_tokens(item.get('text', ''), tokens)) link = item.get('link') if link: href = e(apply_tokens(link.get('action', '#'), tokens)) ltext = e(apply_tokens(link.get('text', ''), tokens)) return f'

{text} {ltext}

' return f'

{text}

' if t == 'spacer': return '
' 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'' if item.get('method', '').lower() == 'post': return ( f'
' f'
' ) if action_raw: return f'{text}' return f'' if t == 'button_cancel': text = e(apply_tokens(item.get('text', 'Cancel'), tokens)) extra_cls = (' ' + item['class']) if item.get('class') else '' return f'' if t == 'header_page_title': return f'' 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)}' if t == 'auth_card': return f'
{build_items(item.get("items", []), tokens, req)}
' if t == 'stat_card_grid': return f'
{build_items(item.get("items", []), tokens, req)}
' 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'
' f'
{label}
' '
' f'{value}' '' '
' f'
{sub}
' '
' ) if edit_action and edit_field: min_attr = f' min="{e(edit_min)}"' if edit_min else '' suffix_html = f'{e(edit_suffix)}' if edit_suffix else '' input_wrap = ( '
' f'' f'{suffix_html}
' ) return ( f'
' f'
{label}
' '
' f'{value}' '' '
' f'' f'
{sub}
' '
' ) return ( f'
' f'
{label}
' f'
{value}
' f'
{sub}
' '
' ) 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'

{e(label)}

' if label else '' body = build_items(item.get('items', []), tokens, req) return f'
{header}
{body}
' 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 ( '
' f'' f'
{badge_text}
' '
' ) if t == 'info_bar': variant = item.get('variant', 'info') text = e(apply_tokens(item.get('text', ''), tokens)) return f'
{text}
' 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'
{text}
' if t == 'credential_fields': psel = e(item.get('provider_select', 'provider')) return ( f'
' '' '' '
' ) 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'
{cells}
' return f'
{rows_html}
' if t == 'grid_label': return f'
{e(apply_tokens(item.get("text", ""), tokens))}
' if t == 'grid_value': return f'
{e(apply_tokens(item.get("text", ""), tokens))}
' 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'' originals = collect_form_originals(item.get('items', []), tokens) orig_field = ( f'' 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'
{hash_field}{orig_field}{inner}
{script}' if t == 'hidden': name = e(item.get('name', '')) value = e(apply_tokens(item.get('value', ''), tokens)) return f'' 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'{e(f.get("label",""))}' for f in fields) + '' 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'' if _vmask: field_inner = ( '
' + inp + '' '
' ) else: field_inner = inp form_rows += ( f'' f'{f_label}:' f'{field_inner}' f'' ) return ( f'
' f'
' f'
' f'' f'' f'{ths}' f'' f'' f'' f'' f'' f'
{empty}
' f'
' f'
' f'{form_rows}
' f'
' f'' f'' f'
' f'
' f'
' f'' f'
' ) 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'

{hint}

' if hint else '' return ( f'
' f'' f'' f'{hint_html}' f'
' ) 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 = '' if _vmask else '' wrap_open = '
' if _vmask else '' wrap_close = '
' if _vmask else '' hint = e(apply_tokens(item.get('hint', ''), tokens)) hint_html = f'

{hint}

' if hint else '' return ( f'
' f'' f'{wrap_open}' f'' f'{dyn_hint_html}' f'{wrap_close}' f'{hint_html}' f'
' ) 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'
{inner}
' 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 ( '
' f'' '
' '
' f'' '/' f'' '
' f'{e(dotted)}' '' '
' '
' ) 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'' if t == 'spacer': return '' 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'
{inner}
' if t == 'table': return build_table(item, tokens, req) if t == 'raw_html': return Markup(apply_tokens(item.get('html', ''), tokens)) return ''