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