2026-05-17 03:26:01 -04:00
from flask import Blueprint , session , redirect , get_flashed_messages
from markupsafe import Markup
import json , re , subprocess , os , sys , html as html_mod
import sanitize
2026-05-20 17:10:18 -04:00
import validation as validate
2026-05-17 03:26:01 -04:00
from datetime import datetime , timezone
2026-05-25 23:57:34 -04:00
from config_utils import config_hash , get_pending_entries , get_dashboard_pending , get_dashboard_done , load_snapshot_for_uuid , queue_command , _apply_changes_immediately , _seconds_until_next_run , _format_timing , _is_locked , _lock_mtime , WEB_APP_DISPLAY_NAME , CONFIGS_DIR , DATA_DIR
2026-05-17 03:26:01 -04:00
bp = Blueprint ( ' view_page ' , __name__ )
LEVEL_RANK = { ' nothing ' : 0 , ' viewer ' : 1 , ' administrator ' : 2 , ' manager ' : 3 }
2026-05-21 03:45:14 -04:00
# Access level ======================================================
2026-05-17 03:26:01 -04:00
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 ' [view_page] WARNING: unknown role " { role } " in client_requirement " { req } " ' , file = sys . stderr )
return False
return check ( needed , level )
print ( f ' [view_page] WARNING: client_requirement " { req } " has no valid suffix (+, -, =) ' , file = sys . stderr )
return False
2026-05-21 03:45:14 -04:00
# File loaders ======================================================
2026-05-17 03:26:01 -04:00
def _load_json ( path ) :
try :
with open ( path ) as f :
return json . load ( f )
except Exception as ex :
print ( f ' [view_page] ERROR loading { path } : { ex } ' , file = sys . stderr )
return { }
2026-05-25 19:59:42 -04:00
def _load_config ( ) : return _load_json ( f ' { CONFIGS_DIR } /config.json ' )
def _load_ddns ( ) : return _load_config ( ) . get ( ' ddns ' , { } )
2026-05-17 03:26:01 -04:00
def _load_accounts ( ) : return _load_json ( f ' { DATA_DIR } /authorized_accounts.json ' )
def _load_css ( ) :
try :
with open ( f ' { DATA_DIR } /page_styles.css ' ) as f :
return f . read ( )
except Exception :
return ' '
2026-05-21 03:45:14 -04:00
# Shell helper ======================================================
2026-05-17 03:26:01 -04:00
def _run ( cmd ) :
try :
r = subprocess . run ( cmd , shell = True , capture_output = True , text = True , timeout = 5 )
return r . stdout . strip ( )
except Exception :
return ' '
2026-05-18 14:38:23 -04:00
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 _get_system_interfaces ( ) :
2026-05-20 04:06:50 -04:00
_EXCLUDE_PREFIXES = ( ' lo ' , ' wg ' , ' docker ' , ' br- ' , ' veth ' ,
' tun ' , ' tap ' , ' ppp ' , ' virbr ' ,
' podman ' , ' vnet ' , ' macvtap ' , ' fc- ' )
try :
return sorted (
n for n in os . listdir ( ' /sys/class/net ' )
if not n . startswith ( _EXCLUDE_PREFIXES )
and os . path . exists ( f ' /sys/class/net/ { n } /device ' )
)
except Exception :
return [ ]
def _iface_info ( iface ) :
base = f ' /sys/class/net/ { iface } '
def _rd ( path ) :
try :
with open ( f ' { base } / { path } ' ) as f :
return f . read ( ) . strip ( )
except Exception :
return None
wireless = os . path . isdir ( f ' { base } /wireless ' )
state = ( _rd ( ' operstate ' ) or ' unknown ' ) . upper ( )
if state == ' UNKNOWN ' :
state = ' UP '
carrier_raw = _rd ( ' carrier ' )
carrier = ( carrier_raw == ' 1 ' ) if carrier_raw is not None else None
speed_raw = _rd ( ' speed ' )
try :
mbps = int ( speed_raw )
if mbps < = 0 :
speed = None
elif mbps > = 1000 and mbps % 1000 == 0 :
speed = f ' { mbps / / 1000 } Gbps '
else :
speed = f ' { mbps } Mbps '
except ( TypeError , ValueError ) :
speed = None
mac = _rd ( ' address ' )
perm_mac = _rd ( ' perm_address ' )
if perm_mac and perm_mac == ' 00:00:00:00:00:00 ' :
perm_mac = None
# DEBUG
# if not perm_mac: perm_mac = 'de:ad:be:ef:f0:0d'
def _int ( val ) :
try : return int ( val ) if val else None
except ValueError : return None
return {
' name ' : iface ,
' wireless ' : wireless ,
' state ' : state ,
' carrier ' : carrier ,
' speed ' : speed ,
' mtu ' : _rd ( ' mtu ' ) ,
' min_mtu ' : _int ( _rd ( ' min_mtu ' ) ) ,
' max_mtu ' : _int ( _rd ( ' max_mtu ' ) ) ,
' mac ' : mac ,
' perm_mac ' : perm_mac ,
}
2026-05-18 14:38:23 -04:00
def _iface_status ( iface ) :
""" Return link state for iface by reading /sys/class/net/<iface>/operstate.
Returns INVALID if the interface does not exist , otherwise UP / DOWN / UNKNOWN / etc . """
if not iface :
return ' INVALID '
safe = re . sub ( r ' [^A-Za-z0-9._-] ' , ' ' , iface )
if not safe :
return ' INVALID '
try :
with open ( f ' /sys/class/net/ { safe } /operstate ' ) as f :
state = f . read ( ) . strip ( ) . upper ( )
return state if state else ' UP '
except OSError :
return ' INVALID '
2026-05-25 19:59:42 -04:00
def _resolve_iface ( vlan , cfg ) :
2026-05-20 17:49:00 -04:00
""" Compute interface name from is_vpn + derived vlan_id + general.lan_interface. """
2026-05-18 14:38:23 -04:00
if vlan . get ( ' is_vpn ' ) :
2026-05-25 19:59:42 -04:00
wg_vlans = [ v for v in cfg . get ( ' vlans ' , [ ] ) if v . get ( ' is_vpn ' ) ]
2026-05-20 17:49:00 -04:00
wg_sorted = sorted ( wg_vlans , key = lambda v : (
validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) is None ,
validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) or 0 ,
) )
idx = next ( ( i for i , v in enumerate ( wg_sorted ) if v is vlan ) , 0 )
2026-05-18 14:38:23 -04:00
return f ' wg { idx } '
2026-05-25 19:59:42 -04:00
lan = cfg . get ( ' network_interfaces ' , { } ) . get ( ' lan_interface ' , ' eth0 ' )
2026-05-20 17:49:00 -04:00
vid = validate . derive_vlan_id ( vlan . get ( ' subnet ' , ' ' ) , vlan . get ( ' subnet_mask ' , 24 ) ) or 1
2026-05-18 14:38:23 -04:00
return lan if vid == 1 else f ' { lan } . { vid } '
2026-05-21 03:45:14 -04:00
# Live data loaders =================================================
2026-05-17 03:26:01 -04:00
def _live_dhcp_leases ( ) :
rows = [ ]
leases_file = ' /var/lib/misc/dnsmasq.leases '
try :
with open ( leases_file ) as f :
for line in f :
parts = line . strip ( ) . split ( )
if len ( parts ) > = 4 :
rows . append ( {
' hostname ' : parts [ 3 ] if parts [ 3 ] != ' * ' else ' - ' ,
' ip_address ' : parts [ 2 ] ,
' mac_address ' : parts [ 1 ] ,
' vlan_name ' : _vlan_name_for_ip ( parts [ 2 ] ) ,
' expires ' : _fmt_timestamp ( int ( parts [ 0 ] ) ) ,
} )
except Exception :
pass
return rows
def _vlan_name_for_ip ( ip ) :
import ipaddress
2026-05-25 19:59:42 -04:00
for vlan in _load_config ( ) . get ( ' vlans ' , [ ] ) :
2026-05-18 14:38:23 -04:00
subnet = vlan . get ( ' subnet ' , ' ' )
mask = vlan . get ( ' subnet_mask ' , 24 )
2026-05-17 03:26:01 -04:00
if not subnet :
continue
try :
2026-05-18 14:38:23 -04:00
if ipaddress . ip_address ( ip ) in ipaddress . ip_network ( f ' { subnet } / { mask } ' , strict = False ) :
2026-05-17 03:26:01 -04:00
return vlan . get ( ' name ' , ' - ' )
except Exception :
pass
return ' - '
def _fmt_timestamp ( ts ) :
try :
return datetime . fromtimestamp ( ts , tz = timezone . utc ) . strftime ( ' % Y- % m- %d % H: % M UTC ' )
except Exception :
return ' - '
2026-05-23 02:01:37 -04:00
def _relative_time ( ts ) :
try :
diff = int ( datetime . now ( tz = timezone . utc ) . timestamp ( ) ) - int ( ts )
if diff < 60 :
n = max ( 0 , diff )
return f ' { n } second { " s " if n != 1 else " " } ago '
m = diff / / 60
if m < 60 :
return f ' { m } minute { " s " if m != 1 else " " } ago '
h = m / / 60
if h < 24 :
return f ' { h } hour { " s " if h != 1 else " " } ago '
d = h / / 24
if d < 365 :
return f ' { d } day { " s " if d != 1 else " " } ago '
y = d / / 365
return f ' { y } year { " s " if y != 1 else " " } ago '
except Exception :
return ' '
2026-05-17 03:26:01 -04:00
def _live_vpn_sessions ( ) :
rows = [ ]
out = _run ( ' wg show all dump 2>/dev/null ' )
for line in out . splitlines ( ) :
parts = line . split ( ' \t ' )
if len ( parts ) == 9 :
interface , _pubkey , _psk , endpoint , allowed_ips , last_hs , rx , tx , _ka = parts
rows . append ( {
' peer_name ' : _pubkey [ : 16 ] + ' ... ' ,
' interface ' : interface ,
' tunnel_ip ' : allowed_ips . split ( ' , ' ) [ 0 ] . split ( ' / ' ) [ 0 ] if allowed_ips else ' - ' ,
' endpoint ' : endpoint if endpoint != ' (none) ' else ' - ' ,
' last_handshake ' : _fmt_timestamp ( int ( last_hs ) ) if last_hs . isdigit ( ) and last_hs != ' 0 ' else ' Never ' ,
' rx_bytes ' : _fmt_bytes ( int ( rx ) ) if rx . isdigit ( ) else ' - ' ,
' tx_bytes ' : _fmt_bytes ( int ( tx ) ) if tx . isdigit ( ) else ' - ' ,
} )
return rows
def _fmt_bytes ( n ) :
for unit in ( ' B ' , ' KB ' , ' MB ' , ' GB ' ) :
if n < 1024 :
return f ' { n : .1f } { unit } '
n / = 1024
return f ' { n : .1f } TB '
2026-05-21 03:45:14 -04:00
# Config data loaders ===============================================
2026-05-17 03:26:01 -04:00
def _config_datasource ( name ) :
2026-05-25 19:59:42 -04:00
cfg = _load_config ( )
vlans = cfg . get ( ' vlans ' , [ ] )
2026-05-17 03:26:01 -04:00
2026-05-18 14:38:23 -04:00
if name == ' interfaces ' :
2026-05-25 19:59:42 -04:00
gen = cfg . get ( ' network_interfaces ' , { } )
2026-05-18 14:38:23 -04:00
wan = gen . get ( ' wan_interface ' , ' ' )
lan = gen . get ( ' lan_interface ' , ' ' )
return [
{ ' iface_type ' : ' WAN ' , ' interface ' : wan , ' status ' : _iface_status ( wan ) } ,
{ ' iface_type ' : ' LAN ' , ' interface ' : lan , ' status ' : _iface_status ( lan ) } ,
]
2026-05-17 03:26:01 -04:00
if name == ' banned_ips ' :
2026-05-25 19:59:42 -04:00
return cfg . get ( ' banned_ips ' , [ ] )
2026-05-17 03:26:01 -04:00
if name == ' host_overrides ' :
2026-05-25 19:59:42 -04:00
return cfg . get ( ' host_overrides ' , [ ] )
2026-05-17 03:26:01 -04:00
if name == ' blocklists ' :
rows = [ ]
2026-05-25 19:59:42 -04:00
for bl in cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] ) :
2026-05-17 03:26:01 -04:00
row = dict ( bl )
bl_path = f ' { CONFIGS_DIR } /blocklists/ { bl . get ( " save_as " , " " ) } '
try :
with open ( bl_path ) as f :
row [ ' domain_count ' ] = str ( sum ( 1 for _ in f ) )
row [ ' last_updated ' ] = _fmt_timestamp ( int ( os . path . getmtime ( bl_path ) ) )
except Exception :
row [ ' domain_count ' ] = ' - '
row [ ' last_updated ' ] = ' - '
rows . append ( row )
return rows
if name == ' vlans ' :
2026-05-25 19:59:42 -04:00
bl_desc = { b [ ' name ' ] : b . get ( ' description ' , b [ ' name ' ] ) for b in cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] ) if ' name ' in b }
2026-05-17 03:26:01 -04:00
rows = [ ]
2026-05-20 17:49:00 -04:00
for v in sorted ( vlans , key = lambda x : validate . derive_vlan_id ( x . get ( ' subnet ' , ' ' ) , x . get ( ' subnet_mask ' , 24 ) ) or 0 ) :
2026-05-25 02:22:21 -04:00
row = { k : v . get ( k ) for k in ( ' name ' , ' subnet ' , ' subnet_mask ' , ' radius_default ' , ' mdns_reflection ' , ' is_vpn ' , ' dnsmasq_log_queries ' ) }
2026-05-20 17:49:00 -04:00
row [ ' vlan_id ' ] = validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) )
2026-05-25 19:59:42 -04:00
row [ ' interface ' ] = _resolve_iface ( v , cfg )
2026-05-18 20:02:22 -04:00
row [ ' use_blocklists ' ] = json . dumps ( [
{ ' n ' : bl , ' d ' : bl_desc . get ( bl , bl ) } for bl in v . get ( ' use_blocklists ' , [ ] )
] )
2026-05-17 03:26:01 -04:00
rows . append ( row )
return rows
if name == ' inter_vlan_exceptions ' :
2026-05-25 19:59:42 -04:00
return cfg . get ( ' inter_vlan_exceptions ' , [ ] )
2026-05-17 03:26:01 -04:00
if name == ' port_forwarding ' :
2026-05-25 19:59:42 -04:00
return cfg . get ( ' port_forwarding ' , [ ] )
2026-05-17 03:26:01 -04:00
if name == ' dhcp_reservations ' :
rows = [ ]
for vlan in vlans :
for res in vlan . get ( ' reservations ' , [ ] ) :
row = dict ( res )
row [ ' vlan_name ' ] = vlan . get ( ' name ' , ' - ' )
rows . append ( row )
return rows
if name == ' ddns_providers ' :
ddns = _load_ddns ( )
rows = [ ]
for p in ddns . get ( ' providers ' , [ ] ) :
row = dict ( p )
ptype = p . get ( ' provider ' , ' ' ) . lower ( )
if ptype == ' noip ' :
2026-05-24 00:08:14 -04:00
row [ ' credentials ' ] = ( f ' <div style= " line-height:1.3 " > '
f ' <b>U:</b> { e ( p . get ( " username " , " - " ) ) } <br/> '
2026-05-24 03:02:10 -04:00
f ' <b>P:</b> ••••••</div> ' )
2026-05-17 03:26:01 -04:00
elif ptype in ( ' cloudflare ' , ' duckdns ' ) :
2026-05-20 04:06:50 -04:00
tok = p . get ( ' api_token ' , ' ' )
2026-05-24 03:02:10 -04:00
row [ ' credentials ' ] = f ' <b>API Token:</b> { e ( tok [ : 20 ] ) } ... ' if tok else ' (not set) '
2026-05-17 03:26:01 -04:00
else :
row [ ' credentials ' ] = ' - '
row [ ' hostnames ' ] = json . dumps ( p . get ( ' hostnames ' , p . get ( ' subdomains ' , [ ] ) ) )
rows . append ( row )
return rows
if name == ' accounts ' :
rows = [ ]
for acct in _load_accounts ( ) . get ( ' accounts ' , [ ] ) :
row = dict ( acct )
row [ ' account_status ' ] = ' active ' if acct . get ( ' hashed_password ' ) else ' pending '
rows . append ( row )
return rows
2026-05-18 14:38:23 -04:00
if name == ' vpn_peers ' :
rows = [ ]
2026-05-20 17:49:00 -04:00
_wg_sorted = sorted (
[ v for v in vlans if v . get ( ' is_vpn ' ) ] ,
key = lambda v : ( validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) is None ,
validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) or 0 )
)
for i , vlan in enumerate ( _wg_sorted ) :
2026-05-20 04:06:50 -04:00
iface = f ' wg { i } '
2026-05-20 17:49:00 -04:00
vlan_display = f ' { iface } (VLAN { validate . derive_vlan_id ( vlan . get ( " subnet " , " " ) , vlan . get ( " subnet_mask " , 24 ) ) or " ? " } ) '
2026-05-20 04:06:50 -04:00
for peer in vlan . get ( ' peers ' , [ ] ) :
row = dict ( peer )
row [ ' vlan_display ' ] = vlan_display
row [ ' split_tunnel ' ] = ' yes ' if peer . get ( ' split_tunnel ' ) else ' no '
row [ ' pubkey_short ' ] = peer . get ( ' public_key ' , ' ' ) [ : 20 ] + ' ... ' if peer . get ( ' public_key ' ) else ' - '
rows . append ( row )
2026-05-18 14:38:23 -04:00
return rows
2026-05-17 03:26:01 -04:00
return [ ]
2026-05-21 03:45:14 -04:00
# Live stat helpers =================================================
2026-05-17 03:26:01 -04:00
def _get_dnsmasq_stats ( ) :
stats = { ' queries ' : ' - ' , ' hits ' : ' - ' , ' hit_rate ' : ' - ' ,
' forwarded ' : ' - ' , ' auth ' : ' - ' , ' tcp_peak ' : ' - ' }
out = _run ( ' journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null ' )
for line in reversed ( out . splitlines ( ) ) :
if ' queries forwarded ' in line :
m = re . search ( r ' queries forwarded ( \ d+) ' , line )
if m : stats [ ' forwarded ' ] = m . group ( 1 )
m = re . search ( r ' queries answered locally ( \ d+) ' , line )
if m : stats [ ' hits ' ] = m . group ( 1 )
fwd = int ( stats [ ' forwarded ' ] ) if stats [ ' forwarded ' ] != ' - ' else 0
hit = int ( stats [ ' hits ' ] ) if stats [ ' hits ' ] != ' - ' else 0
total = fwd + hit
stats [ ' queries ' ] = str ( total ) if total else ' - '
if total > 0 :
stats [ ' hit_rate ' ] = f ' { hit / total * 100 : .0f } % '
break
if ' auth answered ' in line :
m = re . search ( r ' auth answered ( \ d+) ' , line )
if m and stats [ ' auth ' ] == ' - ' :
stats [ ' auth ' ] = m . group ( 1 )
if ' max TCP connections ' in line :
m = re . search ( r ' max TCP connections ( \ d+) ' , line )
if m and stats [ ' tcp_peak ' ] == ' - ' :
stats [ ' tcp_peak ' ] = m . group ( 1 )
return stats
def _count_blocked_today ( ) :
out = _run ( " journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c ' is NXDOMAIN ' " )
return out or ' 0 '
def _count_blocked_domains ( ) :
bl_dir = f ' { CONFIGS_DIR } /blocklists '
try :
total = sum (
int ( _run ( f ' wc -l < " { bl_dir } / { f } " ' ) or 0 )
for f in os . listdir ( bl_dir ) if f . endswith ( ' .conf ' )
)
return str ( total )
except Exception :
return ' - '
def _bl_last_update ( ) :
bl_dir = f ' { CONFIGS_DIR } /blocklists '
try :
mtime = max (
os . path . getmtime ( f ' { bl_dir } / { f } ' )
for f in os . listdir ( bl_dir ) if f . endswith ( ' .conf ' )
)
return _fmt_timestamp ( int ( mtime ) )
except Exception :
return ' - '
2026-05-25 19:59:42 -04:00
def _blocklist_stats_html ( cfg ) :
2026-05-23 00:27:37 -04:00
bl_dir = f ' { CONFIGS_DIR } /blocklists '
rows = ' '
2026-05-25 19:59:42 -04:00
for bl in cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] ) :
2026-05-23 00:27:37 -04:00
name = e ( bl . get ( ' name ' , ' ' ) )
save_as = bl . get ( ' save_as ' , ' ' )
bl_path = f ' { bl_dir } / { save_as } ' if save_as else ' '
try :
with open ( bl_path ) as f :
entries = sum ( 1 for _ in f )
2026-05-23 02:01:37 -04:00
mtime = int ( os . path . getmtime ( bl_path ) )
2026-05-23 00:27:37 -04:00
size_str = _fmt_bytes ( os . path . getsize ( bl_path ) )
2026-05-24 02:09:53 -04:00
last_refreshed = f ' { datetime . fromtimestamp ( mtime ) . strftime ( " % Y- % m- %d % H: % M " ) } ( { _relative_time ( mtime ) } ) '
2026-05-23 00:27:37 -04:00
except Exception :
entries , size_str , last_refreshed = ' - ' , ' - ' , ' Never '
rows + = ( f ' <tr> '
f ' <td class= " table-cell " > { name } </td> '
f ' <td class= " table-cell " > { entries } </td> '
f ' <td class= " table-cell " > { size_str } </td> '
f ' <td class= " table-cell " > { e ( last_refreshed ) } </td> '
f ' </tr> ' )
if not rows :
return ' '
return (
2026-05-25 21:31:20 -04:00
' <table class= " data-table " > '
2026-05-23 00:27:37 -04:00
' <thead><tr> '
' <th class= " table-header " >Blocklist</th> '
' <th class= " table-header " >Entries</th> '
' <th class= " table-header " >Size</th> '
' <th class= " table-header " >Last Refreshed</th> '
' </tr></thead> '
f ' <tbody> { rows } </tbody> '
' </table> '
)
2026-05-23 16:47:19 -04:00
DDNS_LOG_MAX = 50
def _ddns_log_tail ( ) :
2026-05-17 03:26:01 -04:00
log_path = f ' { CONFIGS_DIR } /ddns.log '
try :
2026-05-24 02:28:52 -04:00
log_max_kb = _load_ddns ( ) . get ( ' general ' , { } ) . get ( ' log_max_kb ' , 1024 )
size_kb = os . path . getsize ( log_path ) / 1024
2026-05-17 03:26:01 -04:00
with open ( log_path ) as f :
lines = f . readlines ( )
2026-05-23 16:47:19 -04:00
if not lines :
return ' (log is empty) ' , ' '
total = len ( lines )
tail = lines [ - DDNS_LOG_MAX : ]
shown = len ( tail )
hidden = total - shown
2026-05-24 02:28:52 -04:00
pct = min ( 100 , round ( size_kb / log_max_kb * 100 ) ) if log_max_kb else 0
2026-05-24 03:15:42 -04:00
left = f ' Showing { shown } of { total } lines ( { hidden } not shown) ' if hidden > 0 else f ' Showing { shown } of { total } lines '
2026-05-24 02:28:52 -04:00
right = f ' Log file size: { size_kb : .1f } KB ( { pct } % of max) '
2026-05-23 16:58:27 -04:00
summary = ( f ' <div class= " text-muted " style= " display:flex;justify-content:space-between;margin-top:0.5em; " > '
2026-05-24 02:28:52 -04:00
f ' <span> { left } </span><span> { right } </span></div> ' )
2026-05-23 16:58:27 -04:00
return ' ' . join ( tail ) . strip ( ) , summary
2026-05-17 03:26:01 -04:00
except FileNotFoundError :
2026-05-23 16:47:19 -04:00
return ' (log file not found) ' , ' '
2026-05-17 03:26:01 -04:00
except Exception :
2026-05-23 16:47:19 -04:00
return ' (error reading log) ' , ' '
2026-05-17 03:26:01 -04:00
def _fmt_seconds ( secs ) :
secs = int ( secs )
if secs < 60 :
return f ' { secs } s '
m , s = divmod ( secs , 60 )
if m < 60 :
return f ' { m } m { s } s ' if s else f ' { m } m '
h , m = divmod ( m , 60 )
return f ' { h } h { m } m ' if m else f ' { h } h '
def _parse_interval_to_seconds ( s ) :
m = re . match ( r ' ^( \ d+)([mhd])$ ' , str ( s ) . strip ( ) )
if not m :
return None
val , unit = int ( m . group ( 1 ) ) , m . group ( 2 )
return val * { ' m ' : 60 , ' h ' : 3600 , ' d ' : 86400 } [ unit ]
def _parse_time_remaining ( text ) :
for line in text . splitlines ( ) :
if ' Trigger: ' in line :
total , found = 0 , False
for amt , unit in re . findall ( r ' ( \ d+) \ s*(day|h|min|s) \ b ' , line ) :
total + = int ( amt ) * { ' day ' : 86400 , ' h ' : 3600 , ' min ' : 60 , ' s ' : 1 } [ unit ]
found = True
if found :
return total
return None
def _read_cached_ip ( ) :
2026-05-24 01:23:46 -04:00
""" Return (ip, mtime) from the most recent .ddns-last-ip-* file, or ( ' ' , None). """
2026-05-17 03:26:01 -04:00
try :
2026-05-24 01:23:46 -04:00
best_ip , best_mtime = ' ' , 0.0
2026-05-17 03:26:01 -04:00
for fname in os . listdir ( CONFIGS_DIR ) :
if fname . startswith ( ' .ddns-last-ip- ' ) :
path = f ' { CONFIGS_DIR } / { fname } '
mtime = os . path . getmtime ( path )
if mtime > best_mtime :
ip = open ( path ) . read ( ) . strip ( )
if ip :
best_ip , best_mtime = ip , mtime
2026-05-24 01:23:46 -04:00
return best_ip , ( best_mtime if best_ip else None )
2026-05-17 03:26:01 -04:00
except Exception :
2026-05-24 01:23:46 -04:00
return ' ' , None
2026-05-17 03:26:01 -04:00
def _public_ip_info ( ddns_cfg ) :
2026-05-24 01:23:46 -04:00
""" Return (ip_str, domains_sub, next_interval_str, last_obtained_str) for stat cards. """
2026-05-24 23:17:28 -04:00
enabled_p = [ p for p in ddns_cfg . get ( ' providers ' , [ ] ) if p . get ( ' enabled ' , True ) ]
all_hosts = [ ]
2026-05-17 03:26:01 -04:00
for p in enabled_p :
all_hosts . extend ( p . get ( ' hostnames ' , p . get ( ' subdomains ' , [ ] ) ) )
2026-05-24 23:17:28 -04:00
domains_sub = ' , ' . join ( all_hosts )
ip , mtime = _read_cached_ip ( )
last_obtained = f ' Obtained: { _relative_time ( mtime ) } ' if mtime else ' '
if ip :
return ip , domains_sub , ' - ' , last_obtained
return ' Offline ' , domains_sub , ' - ' , ' '
2026-05-17 03:26:01 -04:00
2026-05-24 01:46:48 -04:00
def _ddns_last_checked ( ) :
try :
2026-05-24 12:22:07 -04:00
mtime = os . path . getmtime ( f ' { CONFIGS_DIR } /.ddns-last-service ' )
return f ' Last checked: { _relative_time ( mtime ) } '
except OSError :
return ' Last checked: --- '
2026-05-24 01:46:48 -04:00
2026-05-17 03:26:01 -04:00
def _vpn_info ( ) :
2026-05-25 19:59:42 -04:00
for vlan in _load_config ( ) . get ( ' vlans ' , [ ] ) :
2026-05-17 03:26:01 -04:00
if ' vpn_information ' in vlan :
return vlan [ ' vpn_information ' ]
return { }
2026-05-21 03:45:14 -04:00
# Token collection ==================================================
2026-05-17 03:26:01 -04:00
def collect_tokens ( ) :
tokens = { }
2026-05-25 19:59:42 -04:00
cfg = _load_config ( )
net = cfg . get ( ' network_interfaces ' , { } )
dns_blk_gen = cfg . get ( ' dns_blocking ' , { } ) . get ( ' general ' , { } )
dns = cfg . get ( ' upstream_dns ' , { } )
vlans = cfg . get ( ' vlans ' , [ ] )
2026-05-25 01:04:47 -04:00
tokens [ ' GENERAL_WAN_INTERFACE ' ] = str ( net . get ( ' wan_interface ' , ' - ' ) )
tokens [ ' GENERAL_LAN_INTERFACE ' ] = str ( net . get ( ' lan_interface ' , ' - ' ) )
tokens [ ' GENERAL_WAN_STATUS ' ] = _iface_status ( net . get ( ' wan_interface ' , ' ' ) )
tokens [ ' GENERAL_LAN_STATUS ' ] = _iface_status ( net . get ( ' lan_interface ' , ' ' ) )
tokens [ ' GENERAL_LOG_MAX_KB ' ] = str ( dns_blk_gen . get ( ' log_max_kb ' , ' - ' ) )
2026-05-18 14:38:23 -04:00
sys_ifaces = _get_system_interfaces ( )
# Always include currently-configured values so dropdowns are never blank.
2026-05-25 01:04:47 -04:00
for configured in [ net . get ( ' wan_interface ' , ' ' ) , net . get ( ' lan_interface ' , ' ' ) ] :
2026-05-18 14:38:23 -04:00
if configured and configured not in sys_ifaces :
sys_ifaces . append ( configured )
sys_ifaces . sort ( )
tokens [ ' NETWORK_INTERFACE_OPTIONS ' ] = json . dumps (
[ { ' value ' : i , ' label ' : i } for i in sys_ifaces ]
)
tokens [ ' NETWORK_INTERFACE_STATUS_OPTIONS ' ] = json . dumps (
2026-05-20 04:06:50 -04:00
[ { ' value ' : i , ' label ' : f ' { i } - { _iface_status ( i ) . title ( ) } ' } for i in sys_ifaces ]
)
iface_data = [ _iface_info ( i ) for i in sys_ifaces ]
tokens [ ' NETWORK_INTERFACE_DATA_JSON ' ] = json . dumps ( iface_data )
max_speed_len = max (
( len ( str ( d . get ( ' speed ' ) or ' ' ) ) for d in iface_data ) ,
default = len ( ' Speed ' )
2026-05-18 14:38:23 -04:00
)
2026-05-20 04:06:50 -04:00
tokens [ ' NETWORK_INTERFACE_STATS_SPEED_PAD ' ] = str ( max ( max_speed_len , len ( ' Speed ' ) ) )
2026-05-18 14:38:23 -04:00
2026-05-25 02:22:21 -04:00
tokens [ ' GENERAL_LOG_ERRORS_ONLY ' ] = ' true ' if dns_blk_gen . get ( ' log_errors_only ' ) else ' false '
tokens [ ' GENERAL_DAILY_EXECUTE_TIME ' ] = str ( dns_blk_gen . get ( ' daily_execute_time_24hr_local ' , ' - ' ) )
2026-05-25 13:49:23 -04:00
tokens [ ' GENERAL_APPLY_ON_SAVE ' ] = ' true ' if session . get ( ' apply_changes_immediately ' , False ) else ' false '
2026-05-22 01:09:23 -04:00
pending_items = get_dashboard_pending ( )
if pending_items :
2026-05-25 21:31:20 -04:00
# Group by command; each group = one row in the Pending Actions table.
from collections import defaultdict
groups = defaultdict ( list )
for _uuid , _ts , cmd , user in pending_items :
groups [ cmd ] . append ( ( _uuid , user ) )
2026-05-22 01:09:23 -04:00
rows = ' '
2026-05-25 21:31:20 -04:00
for cmd , entries in groups . items ( ) :
2026-05-26 00:22:36 -04:00
users = ' , ' . join ( sorted ( { u for _ , u in entries if u and u != ' unknown ' } ) )
required_by = ' , ' . join ( _uuid for _uuid , _ in entries )
2026-05-25 21:31:20 -04:00
rows + = ( f ' <tr> '
f ' <td class= " table-cell " > { e ( cmd ) } </td> '
f ' <td class= " table-cell " > { e ( users ) } </td> '
f ' <td class= " table-cell " > { e ( required_by ) } </td> '
f ' </tr> ' )
2026-05-22 01:09:23 -04:00
pending_html = (
2026-05-25 21:31:20 -04:00
' <table class= " data-table " > '
2026-05-22 01:09:23 -04:00
' <thead><tr> '
2026-05-25 21:31:20 -04:00
' <th class= " table-header " >Command</th> '
2026-05-22 01:09:23 -04:00
' <th class= " table-header " >User</th> '
2026-05-25 21:31:20 -04:00
' <th class= " table-header " >Required By</th> '
2026-05-22 01:09:23 -04:00
' </tr></thead> '
f ' <tbody> { rows } </tbody> '
' </table> '
)
else :
2026-05-25 21:31:20 -04:00
pending_html = ' <p class= " text-muted " >No pending actions.</p> '
tokens [ ' PENDING_ACTIONS_HTML ' ] = pending_html
2026-05-25 17:10:14 -04:00
tokens [ ' NO_PENDING ' ] = ' true ' if not pending_items else ' '
2026-05-17 03:26:01 -04:00
2026-05-25 16:38:08 -04:00
done_items = get_dashboard_done ( )
if done_items :
hist_rows = ' '
2026-05-25 21:31:20 -04:00
_hist_onclick = (
' onclick= " if(event.target.type!== \' checkbox \' ) '
' this.nextElementSibling.hidden=!this.nextElementSibling.hidden " '
)
2026-05-25 16:38:08 -04:00
for _uuid , applied_ts in done_items :
snap = load_snapshot_for_uuid ( _uuid )
if applied_ts :
dt_str = datetime . fromtimestamp ( applied_ts ) . strftime ( ' % Y- % m- %d % H: % M ' )
else :
dt_str = ' - '
2026-05-25 20:46:17 -04:00
snap_desc = e ( snap [ ' description ' ] ) if snap else ' '
before_val = snap . get ( ' before ' ) if snap else None
after_val = snap . get ( ' after ' ) if snap else None
snap_id = e ( _uuid [ : 8 ] ) if snap else ' '
snap_user = e ( snap [ ' user ' ] ) if snap else ' '
2026-05-25 21:31:20 -04:00
hist_rows + = ( f ' <tr class= " row-expandable " { _hist_onclick } > '
f ' <td class= " table-cell " ><input type= " checkbox " name= " selected_uuids " value= " { e ( _uuid ) } " /></td> '
2026-05-25 16:38:08 -04:00
f ' <td class= " table-cell " > { e ( dt_str ) } </td> '
f ' <td class= " table-cell " > { snap_desc } </td> '
2026-05-25 20:46:17 -04:00
f ' <td class= " table-cell " > { _render_snap_val ( before_val ) } </td> '
f ' <td class= " table-cell " > { _render_snap_val ( after_val ) } </td> '
2026-05-25 16:38:08 -04:00
f ' <td class= " table-cell " > { snap_id } </td> '
f ' <td class= " table-cell " > { snap_user } </td> '
2026-05-25 20:46:17 -04:00
f ' </tr> '
2026-05-25 21:31:20 -04:00
f ' { _snap_expand_row ( before_val , after_val , 7 ) } ' )
select_all = (
' <input type= " checkbox " '
' onchange= " document.querySelectorAll( \' [name=selected_uuids] \' ).forEach(c=>c.checked=this.checked) " /> '
)
2026-05-25 16:38:08 -04:00
history_html = (
2026-05-25 21:31:20 -04:00
' <table class= " data-table " > '
2026-05-25 16:38:08 -04:00
' <thead><tr> '
2026-05-25 21:31:20 -04:00
f ' <th class= " table-header " > { select_all } </th> '
2026-05-25 16:38:08 -04:00
' <th class= " table-header " >Applied</th> '
' <th class= " table-header " >Description</th> '
' <th class= " table-header " >Before</th> '
' <th class= " table-header " >After</th> '
' <th class= " table-header " >Snapshot</th> '
' <th class= " table-header " >User</th> '
' </tr></thead> '
f ' <tbody> { hist_rows } </tbody> '
' </table> '
)
else :
2026-05-25 17:13:49 -04:00
history_html = ' <p class= " text-muted " >No change history.</p> '
2026-05-25 16:38:08 -04:00
tokens [ ' CHANGE_HISTORY_HTML ' ] = history_html
2026-05-25 21:31:20 -04:00
tokens [ ' NO_HISTORY ' ] = ' true ' if not done_items else ' '
2026-05-25 16:38:08 -04:00
2026-05-17 03:26:01 -04:00
servers = dns . get ( ' upstream_servers ' , [ ] )
tokens [ ' DNS_STRICT_ORDER ' ] = ' true ' if dns . get ( ' strict_order ' ) else ' false '
tokens [ ' DNS_CACHE_SIZE ' ] = str ( dns . get ( ' cache_size ' , ' - ' ) )
tokens [ ' DNS_UPSTREAM_SERVERS_JSON ' ] = json . dumps ( servers )
tokens [ ' OVERVIEW_UPSTREAM_SERVERS ' ] = ' , ' . join ( servers ) or ' - '
2026-05-18 14:38:23 -04:00
non_vpn_vlans = [ v for v in vlans if not v . get ( ' is_vpn ' ) ]
2026-05-17 03:26:01 -04:00
vlan_names = [ v . get ( ' name ' , ' ' ) for v in vlans ]
tokens [ ' OVERVIEW_VLAN_NAMES ' ] = ' , ' . join ( vlan_names ) or ' - '
tokens [ ' STAT_VLAN_COUNT ' ] = str ( len ( non_vpn_vlans ) )
tokens [ ' STAT_LEASE_COUNT ' ] = str ( len ( _live_dhcp_leases ( ) ) )
filter_opts = ' <option value= " all " >All VLANs</option> ' + ' ' . join (
f ' <option value= " { e ( n ) } " > { e ( n ) } </option> ' for n in vlan_names
)
tokens [ ' VLAN_FILTER_OPTIONS ' ] = filter_opts
tokens [ ' VLAN_NAMES_AS_OPTIONS ' ] = json . dumps ( [ { ' value ' : n , ' label ' : n } for n in vlan_names ] )
2026-05-18 14:38:23 -04:00
tokens [ ' VPN_VLAN_COUNT ' ] = str ( sum ( 1 for v in vlans if v . get ( ' is_vpn ' ) ) )
2026-05-20 17:49:00 -04:00
tokens [ ' EXISTING_VLAN_IDS_JSON ' ] = json . dumps ( [ validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) for v in vlans ] )
2026-05-18 14:38:23 -04:00
tokens [ ' EXISTING_VLAN_NAMES_JSON ' ] = json . dumps ( [ v . get ( ' name ' , ' ' ) for v in vlans ] )
2026-05-25 19:59:42 -04:00
tokens [ ' EXISTING_VLAN_INTERFACES_JSON ' ] = json . dumps ( [ _resolve_iface ( v , cfg ) for v in vlans ] )
tokens [ ' STAT_BANNED_IP_COUNT ' ] = str ( sum ( 1 for b in cfg . get ( ' banned_ips ' , [ ] ) if b . get ( ' enabled ' , True ) ) )
tokens [ ' STAT_BLOCKLIST_COUNT ' ] = str ( len ( cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] ) ) )
tokens [ ' BLOCKLIST_STATS_HTML ' ] = _blocklist_stats_html ( cfg )
2026-05-17 03:26:01 -04:00
ddns = _load_ddns ( )
2026-05-24 02:28:52 -04:00
ddns_gen = ddns . get ( ' general ' , { } )
tokens [ ' DDNS_TIMER_INTERVAL ' ] = ddns_gen . get ( ' timer_interval ' , ' - ' )
2026-05-24 02:42:11 -04:00
_interval_secs = _parse_interval_to_seconds ( ddns_gen . get ( ' timer_interval ' , ' ' ) ) or 600
tokens [ ' DDNS_TIMER_INTERVAL_MINS ' ] = str ( _interval_secs / / 60 )
2026-05-24 02:28:52 -04:00
tokens [ ' DDNS_GEN_LOG_MAX_KB ' ] = str ( ddns_gen . get ( ' log_max_kb ' , 1024 ) )
tokens [ ' DDNS_GEN_LOG_ERRORS_ONLY ' ] = ' true ' if ddns_gen . get ( ' log_errors_only ' ) else ' false '
2026-05-17 03:26:01 -04:00
enabled_p = [ p for p in ddns . get ( ' providers ' , [ ] ) if p . get ( ' enabled ' , True ) ]
tokens [ ' STAT_DDNS_PROVIDER_COUNT ' ] = str ( len ( enabled_p ) )
2026-05-24 03:02:10 -04:00
_ip_check = ddns . get ( ' ip_check_services ' , [ ] )
_http_svc = [ s [ ' url ' ] for s in _ip_check if s . get ( ' type ' ) == ' http ' ]
_dig_svc = [ s [ ' url ' ] for s in _ip_check if s . get ( ' type ' ) == ' dig ' ]
tokens [ ' STAT_IP_CHECK_TOTAL ' ] = str ( len ( _ip_check ) )
tokens [ ' STAT_IP_CHECK_SUB ' ] = f ' { len ( _http_svc ) } http and { len ( _dig_svc ) } dig '
tokens [ ' IP_CHECK_HTTP_JSON ' ] = json . dumps ( _http_svc )
tokens [ ' IP_CHECK_DIG_JSON ' ] = json . dumps ( _dig_svc )
2026-05-18 20:02:22 -04:00
_ddns_labels = { ' noip ' : ' No-IP ' , ' cloudflare ' : ' Cloudflare ' , ' duckdns ' : ' DuckDNS ' }
2026-05-17 03:26:01 -04:00
tokens [ ' DDNS_PROVIDER_OPTIONS ' ] = json . dumps ( [
2026-05-18 20:02:22 -04:00
{ ' value ' : p , ' label ' : _ddns_labels . get ( p , p . title ( ) ) }
for p in validate . VALID_DDNS_PROVIDERS
2026-05-17 03:26:01 -04:00
] )
2026-05-20 17:49:00 -04:00
wg_vlans_list = sorted (
[ v for v in vlans if v . get ( ' is_vpn ' ) ] ,
key = lambda v : ( validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) is None ,
validate . derive_vlan_id ( v . get ( ' subnet ' , ' ' ) , v . get ( ' subnet_mask ' , 24 ) ) or 0 )
)
2026-05-20 04:06:50 -04:00
tokens [ ' VPN_VLAN_OPTIONS ' ] = json . dumps ( [
2026-05-20 17:49:00 -04:00
{ ' value ' : v . get ( ' name ' , ' ' ) , ' label ' : f ' wg { i } (VLAN { validate . derive_vlan_id ( v . get ( " subnet " , " " ) , v . get ( " subnet_mask " , 24 ) ) or " ? " } ) ' }
2026-05-20 04:06:50 -04:00
for i , v in enumerate ( wg_vlans_list )
] )
wg_vlan = wg_vlans_list [ 0 ] if wg_vlans_list else { }
2026-05-18 14:38:23 -04:00
vpn = wg_vlan . get ( ' vpn_information ' , { } )
2026-05-17 03:26:01 -04:00
overrides = vpn . get ( ' explicit_overrides ' , { } )
2026-05-18 14:38:23 -04:00
tokens [ ' VPN_LISTEN_PORT ' ] = str ( vpn . get ( ' listen_port ' , ' ' ) )
tokens [ ' VPN_SERVER_ENDPOINT ' ] = str ( vpn . get ( ' server_endpoint ' , ' ' ) )
tokens [ ' VPN_DOMAIN ' ] = str ( vpn . get ( ' domain ' , ' ' ) )
tokens [ ' VPN_DNS_SERVER ' ] = str ( overrides . get ( ' dns_server ' , ' ' ) )
tokens [ ' VPN_MTU ' ] = str ( overrides . get ( ' mtu ' , ' ' ) )
# Compute gateway from server_identities (lowest last-octet), fallback to first subnet host
try :
import ipaddress as _ipaddress
ident_ips = [ s [ ' ip ' ] for s in wg_vlan . get ( ' server_identities ' , [ ] ) if s . get ( ' ip ' ) ]
if ident_ips :
default_gw = str ( min ( ( _ipaddress . IPv4Address ( ip ) for ip in ident_ips ) ,
key = lambda x : x . packed [ - 1 ] ) )
else :
wg_net = _ipaddress . IPv4Network (
f " { wg_vlan [ ' subnet ' ] } / { wg_vlan [ ' subnet_mask ' ] } " , strict = False )
default_gw = str ( next ( wg_net . hosts ( ) ) )
tokens [ ' VPN_GATEWAY ' ] = overrides . get ( ' gateway ' ) or default_gw
except Exception :
tokens [ ' VPN_GATEWAY ' ] = ' '
2026-05-17 03:26:01 -04:00
2026-05-24 01:23:46 -04:00
ip_str , sub_str , next_interval , last_obtained = _public_ip_info ( ddns )
2026-05-24 01:46:48 -04:00
tokens [ ' STAT_PUBLIC_IP ' ] = ip_str
tokens [ ' STAT_DDNS_HOSTNAME ' ] = sub_str
tokens [ ' STAT_DDNS_NEXT_INTERVAL ' ] = next_interval
2026-05-24 01:23:46 -04:00
tokens [ ' STAT_PUBLIC_IP_LAST_OBTAINED ' ] = last_obtained
2026-05-24 01:46:48 -04:00
tokens [ ' STAT_PUBLIC_IP_LAST_CHECKED ' ] = _ddns_last_checked ( )
2026-05-23 16:47:19 -04:00
tokens [ ' DDNS_LOG_TAIL ' ] , tokens [ ' DDNS_LOG_SUMMARY ' ] = _ddns_log_tail ( )
2026-05-17 03:26:01 -04:00
tokens [ ' STAT_UPTIME ' ] = _run ( ' uptime -p ' ) or ' - '
tokens [ ' STAT_NFTABLES_STATUS ' ] = ' Active ' if _run ( ' nft list tables 2>/dev/null ' ) . strip ( ) else ' Inactive '
dns_stats = _get_dnsmasq_stats ( )
tokens [ ' DNS_STAT_QUERIES ' ] = dns_stats [ ' queries ' ]
tokens [ ' DNS_STAT_HITS ' ] = dns_stats [ ' hits ' ]
tokens [ ' DNS_STAT_HIT_RATE ' ] = dns_stats [ ' hit_rate ' ]
tokens [ ' DNS_STAT_FORWARDED ' ] = dns_stats [ ' forwarded ' ]
tokens [ ' DNS_STAT_AUTH ' ] = dns_stats [ ' auth ' ]
tokens [ ' DNS_STAT_TCP_PEAK ' ] = dns_stats [ ' tcp_peak ' ]
tokens [ ' STAT_BLOCKED_TODAY ' ] = _count_blocked_today ( )
tokens [ ' STAT_BLOCKED_DOMAINS ' ] = _count_blocked_domains ( )
tokens [ ' STAT_BL_LAST_UPDATE ' ] = _bl_last_update ( )
tokens [ ' PREF_EMAIL ' ] = session . get ( ' email_address ' , ' ' )
tokens [ ' PREF_TIMEZONE ' ] = session . get ( ' timezone ' , ' ' )
blank = [ { ' value ' : ' ' , ' label ' : ' -- Select timezone -- ' } ]
tokens [ ' TIMEZONE_OPTIONS ' ] = json . dumps (
blank + [ { ' value ' : tz , ' label ' : tz } for tz in sanitize . VALID_TIMEZONES ]
)
2026-05-18 14:38:23 -04:00
tokens [ ' PROTOCOL_OPTIONS ' ] = json . dumps ( [
{ ' value ' : ' tcp ' , ' label ' : ' TCP ' } ,
{ ' value ' : ' udp ' , ' label ' : ' UDP ' } ,
{ ' value ' : ' both ' , ' label ' : ' TCP/UDP ' } ,
] )
tokens [ ' BLOCKLIST_FORMAT_OPTIONS ' ] = json . dumps ( [
{ ' value ' : ' hosts ' , ' label ' : ' hosts (hosts file format) ' } ,
{ ' value ' : ' dnsmasq ' , ' label ' : ' dnsmasq (local=/ syntax) ' } ,
] )
tokens [ ' BLOCKLIST_NAME_OPTIONS ' ] = json . dumps ( [
{ ' value ' : bl . get ( ' name ' , ' ' ) , ' label ' : bl . get ( ' description ' , bl . get ( ' name ' , ' ' ) ) }
2026-05-25 19:59:42 -04:00
for bl in cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] )
2026-05-18 14:38:23 -04:00
] )
tokens [ ' ACCOUNT_LEVEL_OPTIONS ' ] = json . dumps ( [
{ ' value ' : ' viewer ' , ' label ' : ' Viewer (read-only access to live data) ' } ,
{ ' value ' : ' administrator ' , ' label ' : ' Administrator (can modify configuration) ' } ,
{ ' value ' : ' manager ' , ' label ' : ' Manager (full access including account management) ' } ,
] )
2026-05-17 03:26:01 -04:00
return tokens
2026-05-21 03:45:14 -04:00
# HTML helpers ======================================================
2026-05-17 03:26:01 -04:00
def e ( text ) :
return html_mod . escape ( str ( text ) )
2026-05-25 16:07:21 -04:00
2026-05-25 20:46:17 -04:00
def _snap_text ( val ) :
""" Return the plain-text representation of a snapshot before/after value. """
2026-05-25 16:07:21 -04:00
if val is None :
return ' '
if isinstance ( val , dict ) and len ( val ) == 1 :
k , v = next ( iter ( val . items ( ) ) )
2026-05-25 20:46:17 -04:00
return f ' { k } : { v } '
if isinstance ( val , ( dict , list ) ) :
return json . dumps ( val , separators = ( ' , ' , ' : ' ) )
return str ( val )
def _render_snap_val ( val ) :
""" Return truncated escaped HTML for a snapshot before/after table cell. """
text = _snap_text ( val )
if not text :
return ' '
2026-05-25 16:07:21 -04:00
trunc = ( text [ : 23 ] + ' … ' ) if len ( text ) > 24 else text
2026-05-25 20:46:17 -04:00
return e ( trunc )
def _snap_expand_row ( before_val , after_val , colspan ) :
""" Return a hidden <tr> that expands with full before/after content. """
def box ( label , val ) :
text = _snap_text ( val ) if val is not None else ' '
if isinstance ( val , ( dict , list ) ) :
text = json . dumps ( val , indent = 2 )
2026-05-25 21:31:20 -04:00
body = e ( text ) if text else ' <em class= " snap-expand-none " >(none)</em> '
return ( f ' <div class= " snap-expand-col " > '
f ' <span class= " snap-expand-label " > { label } </span> '
f ' <pre class= " snap-expand-pre " > { body } </pre></div> ' )
inner = f ' <div class= " snap-expand-cols " > { box ( " Before " , before_val ) } { box ( " After " , after_val ) } </div> '
return f ' <tr hidden><td colspan= " { colspan } " class= " snap-expand-cell " > { inner } </td></tr> '
2026-05-25 16:07:21 -04:00
2026-05-17 03:26:01 -04:00
def apply_tokens ( text , tokens ) :
""" Substitute % TOKEN % placeholders. Values are NOT auto-escaped - callers
that use results in HTML attribute or text context should call e ( ) around
the expanded value ( or around individual fields ) as appropriate . """
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
2026-05-21 03:45:14 -04:00
# Content item renderers ============================================
2026-05-17 03:26:01 -04:00
def render_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 ( _render_item ( item , tokens , req ) )
return ' ' . join ( parts )
def _render_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> '
2026-05-24 02:28:52 -04:00
if t == ' hr ' :
2026-05-24 02:42:11 -04:00
return ' <hr class= " divider " /> '
2026-05-24 02:28:52 -04:00
2026-05-17 03:26:01 -04:00
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> '
2026-05-18 14:38:23 -04:00
2026-05-17 03:26:01 -04:00
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 } '
2026-05-25 14:50:03 -04:00
text = e ( apply_tokens ( item . get ( ' text ' , ' ' ) , tokens ) )
2026-05-25 21:49:47 -04:00
action_raw = item . get ( ' action ' , ' ' )
action = e ( apply_tokens ( action_raw , tokens ) )
2026-05-25 17:10:14 -04:00
disabled_val = apply_tokens ( str ( item . get ( ' disabled ' , ' ' ) ) , tokens )
disabled = ' disabled ' if disabled_val and disabled_val not in ( ' false ' , ' 0 ' ) else ' '
2026-05-25 14:50:03 -04:00
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> '
2026-05-17 03:26:01 -04:00
if item . get ( ' method ' , ' ' ) . lower ( ) == ' post ' :
2026-05-25 21:31:20 -04:00
return ( f ' <form method= " post " action= " { action } " class= " form-inline " > '
2026-05-18 14:38:23 -04:00
f ' <button type= " submit " class= " btn { e ( cls ) } " { disabled } > { text } </button></form> ' )
2026-05-25 21:49:47 -04:00
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> '
2026-05-17 03:26:01 -04:00
if t == ' button_cancel ' :
2026-05-24 03:02:10 -04:00
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> '
2026-05-17 03:26:01 -04:00
if t == ' page_header ' :
return f ' <div class= " page-header " > { render_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 } " > { render_items ( item . get ( " items " , [ ] ) , tokens , req ) } </ { tag } > '
if t == ' auth_card ' :
return f ' <div class= " auth-card " > { render_items ( item . get ( " items " , [ ] ) , tokens , req ) } </div> '
if t == ' stat_card_grid ' :
return f ' <div class= " stat-card-grid " > { render_items ( item . get ( " items " , [ ] ) , tokens , req ) } </div> '
if t == ' stat_card ' :
2026-05-24 02:28:52 -04:00
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 " " } '
2026-05-24 02:42:11 -04:00
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 )
2026-05-24 03:02:10 -04:00
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> '
2026-05-25 21:31:20 -04:00
f ' <div class= " stat-card-value-row " > '
2026-05-24 03:02:10 -04:00
f ' <span class= " stat-card-value " > { value } </span> '
f ' <button type= " button " class= " btn btn-ghost btn-sm " '
f ' data-reveal-card= " { e ( reveal_card_id ) } " >Edit</button> '
f ' </div> '
f ' <div class= " stat-card-sub " > { sub } </div> '
f ' </div> '
)
2026-05-24 02:28:52 -04:00
if edit_action and edit_field :
2026-05-24 02:42:11 -04:00
min_attr = f ' min= " { e ( edit_min ) } " ' if edit_min else ' '
suffix_html = f ' <span> { e ( edit_suffix ) } </span> ' if edit_suffix else ' '
2026-05-25 21:31:20 -04:00
input_wrap = ( f ' <div class= " stat-card-value-row " > '
2026-05-24 02:42:11 -04:00
f ' <input type= " { e ( edit_input_type ) } " name= " { e ( edit_field ) } " value= " { e ( edit_raw ) } " '
2026-05-25 21:31:20 -04:00
f ' data-original= " { e ( edit_raw ) } " class= " form-input stat-card-edit-input " { min_attr } /> '
2026-05-24 02:42:11 -04:00
f ' { suffix_html } </div> ' )
2026-05-24 02:28:52 -04:00
return (
f ' <div class= " { cls } stat-card-editable " > '
f ' <div class= " stat-card-label " > { label } </div> '
f ' <div class= " stat-card-view " > '
f ' <span class= " stat-card-value " > { value } </span> '
f ' <button type= " button " class= " btn btn-ghost btn-sm stat-card-edit-btn " >Edit</button> '
f ' </div> '
2026-05-25 21:31:20 -04:00
f ' <form class= " stat-card-edit-form hidden " action= " { e ( edit_action ) } " method= " post " > '
2026-05-25 19:59:42 -04:00
f ' <input type= " hidden " name= " config_hash " value= " { e ( config_hash ( ) ) } " /> '
2026-05-24 02:42:11 -04:00
f ' { input_wrap } '
2026-05-25 21:31:20 -04:00
f ' <div class= " stat-card-edit-actions " > '
2026-05-24 02:42:11 -04:00
f ' <button type= " submit " class= " btn btn-primary btn-sm " disabled>Save</button> '
2026-05-24 02:28:52 -04:00
f ' <button type= " button " class= " btn btn-secondary btn-sm stat-card-cancel-btn " >Cancel</button> '
f ' </div> '
f ' </form> '
f ' <div class= " stat-card-sub " > { sub } </div> '
f ' </div> '
)
2026-05-17 03:26:01 -04:00
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> '
f ' </div> ' )
if t == ' card ' :
label = item . get ( ' label ' , ' ' )
id_attr = f ' id= " { e ( item [ " id " ] ) } " ' if item . get ( ' id ' ) else ' '
2026-05-25 21:31:20 -04:00
cls_hidden = ' hidden ' if item . get ( ' hidden ' ) else ' '
2026-05-17 03:26:01 -04:00
header = f ' <div class= " card-header " ><h2 class= " card-title " > { e ( label ) } </h2></div> ' if label else ' '
body = render_items ( item . get ( ' items ' , [ ] ) , tokens , req )
2026-05-25 21:31:20 -04:00
return f ' <div class= " card { cls_hidden } " { id_attr } > { header } <div class= " card-body " > { body } </div></div> '
2026-05-17 03:26:01 -04:00
2026-05-20 04:06:50 -04:00
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 ' <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> '
f ' </div> ' )
2026-05-17 03:26:01 -04:00
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- { e ( variant ) } " > { text } </div> '
if t == ' pre_block ' :
2026-05-23 17:02:41 -04:00
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> '
2026-05-17 03:26:01 -04:00
if t == ' credential_fields ' :
psel = e ( item . get ( ' provider_select ' , ' provider ' ) )
return (
f ' <div class= " credential-fields " data-provider-select= " { psel } " > '
2026-05-25 21:31:20 -04:00
f ' <div class= " cred-group-token hidden " > '
2026-05-17 03:26:01 -04:00
f ' <div class= " form-group " ><label class= " form-label " >API Token</label> '
2026-05-24 00:08:14 -04:00
f ' <input type= " text " name= " api_token " class= " form-input " /></div> '
2026-05-17 03:26:01 -04:00
f ' </div> '
2026-05-25 21:31:20 -04:00
f ' <div class= " cred-group-noip hidden " > '
2026-05-17 03:26:01 -04:00
f ' <div class= " form-group " ><label class= " form-label " >Username</label> '
2026-05-24 00:08:14 -04:00
f ' <input type= " text " name= " username " class= " form-input " /></div> '
2026-05-17 03:26:01 -04:00
f ' <div class= " form-group " ><label class= " form-label " >Password</label> '
2026-05-24 00:08:14 -04:00
f ' <input type= " password " name= " password " class= " form-input " /></div> '
2026-05-17 03:26:01 -04:00
f ' </div> '
f ' </div> '
)
if t == ' grid ' :
rows_html = ' '
for row in item . get ( ' rows ' , [ ] ) :
cells = ' ' . join ( _render_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 = render_items ( item . get ( ' items ' , [ ] ) , tokens , req )
2026-05-25 19:59:42 -04:00
hash_field = f ' <input type= " hidden " name= " config_hash " value= " { e ( config_hash ( ) ) } " /> '
2026-05-25 16:38:08 -04:00
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 ' ' )
2026-05-20 04:06:50 -04:00
return f ' <form action= " { action } " method= " { method } " > { hash_field } { orig_field } { inner } </form> '
if t == ' hidden ' :
name = e ( item . get ( ' name ' , ' ' ) )
value = e ( apply_tokens ( item . get ( ' value ' , ' ' ) , tokens ) )
2026-05-24 00:08:14 -04:00
return f ' <input type= " hidden " name= " { name } " value= " { value } " /> '
2026-05-17 03:26:01 -04:00
if t == ' field ' :
return _render_field ( item , tokens )
2026-05-18 14:38:23 -04:00
if t == ' field_row ' :
inner = render_items ( item . get ( ' items ' , [ ] ) , tokens , req )
cols = item . get ( ' cols ' , 2 )
return f ' <div class= " form-row- { cols } " > { inner } </div> '
if t == ' subnet_row ' :
subnet_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 ) )
show_derived = item . get ( ' show_derived_vlan_id ' , False )
try :
pf = max ( 1 , min ( 30 , int ( prefix_raw ) ) )
except ( ValueError , TypeError ) :
pf = 24
dotted = _prefix_to_dotted ( pf )
return (
f ' <div class= " form-group " > '
f ' <label class= " form-label " >Subnet</label> '
f ' <div class= " subnet-row-wrap " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " text " name= " { subnet_name } " value= " { e ( subnet_val ) } " placeholder= " { subnet_ph } " class= " form-input " /> '
2026-05-18 14:38:23 -04:00
f ' <span class= " subnet-sep " >/</span> '
2026-05-24 00:08:14 -04:00
f ' <input type= " number " name= " { prefix_name } " value= " { pf } " min= " 1 " max= " 30 " class= " form-input subnet-prefix-input " /> '
2026-05-18 14:38:23 -04:00
f ' <span class= " subnet-dotted " > { e ( dotted ) } </span> '
f ' </div> '
2026-05-25 21:31:20 -04:00
f ' <p class= " form-hint field-dyn-hint hidden " ></p> '
2026-05-18 14:38:23 -04:00
f ' </div> '
)
2026-05-17 03:26:01 -04:00
if t == ' editable_list ' :
return _render_editable_list ( item , tokens )
if t == ' select ' :
2026-05-18 14:38:23 -04:00
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> '
2026-05-17 03:26:01 -04:00
2026-05-24 03:47:34 -04:00
if t == ' spacer ' :
2026-05-24 03:52:26 -04:00
return ' <span style= " margin-left:auto " ></span> '
2026-05-24 03:47:34 -04:00
2026-05-17 03:26:01 -04:00
if t == ' button_row ' :
2026-05-24 03:56:22 -04:00
justify = item . get ( ' justify ' , ' ' )
style_attr = f ' style= " justify-content: { e ( justify ) } " ' if justify else ' '
2026-05-17 03:26:01 -04:00
inner = render_items ( item . get ( ' items ' , [ ] ) , tokens , req )
2026-05-24 03:56:22 -04:00
return f ' <div class= " button-row " { style_attr } > { inner } </div> '
2026-05-17 03:26:01 -04:00
if t == ' table ' :
return _render_table ( item , tokens , req )
2026-05-22 01:09:23 -04:00
if t == ' raw_html ' :
return Markup ( apply_tokens ( item . get ( ' html ' , ' ' ) , tokens ) )
2026-05-17 03:26:01 -04:00
return ' '
def _render_field ( item , tokens ) :
label = e ( item . get ( ' label ' , ' ' ) )
name = e ( item . get ( ' name ' , ' ' ) )
input_type = item . get ( ' input_type ' , ' text ' )
value = apply_tokens ( item . get ( ' value ' , ' ' ) , tokens )
placeholder = e ( apply_tokens ( item . get ( ' placeholder ' , ' ' ) , tokens ) )
hint = e ( apply_tokens ( item . get ( ' hint ' , ' ' ) , tokens ) )
hint_html = f ' <p class= " form-hint " > { hint } </p> ' if hint else ' '
2026-05-18 14:38:23 -04:00
extra_cls = f ' { e ( item [ " class " ] ) } ' if item . get ( ' class ' ) else ' '
readonly = ' readonly ' if item . get ( ' readonly ' ) else ' '
2026-05-17 03:26:01 -04:00
if input_type == ' hidden ' :
2026-05-24 00:08:14 -04:00
return f ' <input type= " hidden " name= " { name } " value= " { e ( value ) } " /> '
2026-05-17 03:26:01 -04:00
if input_type == ' checkbox ' :
2026-05-18 14:38:23 -04:00
checked = ' checked ' if value . lower ( ) in ( ' true ' , ' 1 ' , ' yes ' ) else ' '
cb_label = item . get ( ' checkbox_label ' )
if cb_label :
2026-05-24 02:42:11 -04:00
label_html = f ' <label class= " form-label " > { label } </label> ' if label else ' '
2026-05-18 14:38:23 -04:00
return ( f ' <div class= " form-group " > '
2026-05-24 02:42:11 -04:00
f ' { label_html } '
2026-05-18 14:38:23 -04:00
f ' <label class= " form-checkbox-row " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " checkbox " name= " { name } " { checked } class= " form-checkbox " /> '
2026-05-18 14:38:23 -04:00
f ' <span class= " form-checkbox-label " > { e ( cb_label ) } </span> '
f ' </label> { hint_html } </div> ' )
2026-05-17 03:26:01 -04:00
return ( f ' <div class= " form-group " > '
f ' <label class= " form-label " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " checkbox " name= " { name } " { checked } class= " form-checkbox " /> { label } '
2026-05-17 03:26:01 -04:00
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 (
f ' <label class= " checkbox-group-item " > '
f ' <input type= " checkbox " name= " { name } " value= " { e ( o . get ( " value " , " " ) ) } " '
2026-05-24 00:08:14 -04:00
f ' { " checked " if o . get ( " value " ) in selected else " " } /> { e ( o . get ( " label " , " " ) ) } '
2026-05-17 03:26:01 -04:00
f ' </label> '
for o in opts
)
return ( f ' <div class= " form-group " ><label class= " form-label " > { label } </label> '
f ' <div class= " checkbox-group " > { boxes } </div> { hint_html } </div> ' )
if input_type == ' select ' :
options = item . get ( ' options ' , [ ] )
if isinstance ( options , str ) :
try :
options = json . loads ( apply_tokens ( options , tokens ) )
except Exception :
options = [ ]
current = apply_tokens ( item . get ( ' value ' , ' ' ) , tokens )
opts_html = ' ' . join (
f ' <option value= " { e ( o [ " value " ] ) } " { " selected " if o [ " value " ] == current else " " } > { e ( o [ " label " ] ) } </option> '
for o in options
)
return ( f ' <div class= " form-group " ><label class= " form-label " > { label } </label> '
2026-05-20 04:06:50 -04:00
f ' <select name= " { name } " class= " form-select { extra_cls } " > { opts_html } </select> '
2026-05-17 03:26:01 -04:00
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 ' '
2026-05-25 21:31:20 -04:00
dyn_hint_html = ' <p class= " form-hint field-dyn-hint hidden " ></p> '
2026-05-24 02:42:11 -04:00
inp = ( f ' <input type= " number " name= " { name } " value= " { e ( value ) } " { min_attr } { max_attr } '
2026-05-25 02:51:38 -04:00
f ' class= " form-input { extra_cls } " { readonly } '
f ' data-validate= " positive_int " /> ' )
2026-05-24 02:42:11 -04:00
if item . get ( ' layout ' ) == ' inline ' :
return ( f ' <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> '
2026-05-25 02:51:38 -04:00
f ' <div style= " width:6rem " > { inp } { dyn_hint_html } </div> '
2026-05-24 02:42:11 -04:00
f ' { hint_html } </div> ' )
2026-05-17 03:26:01 -04:00
return ( f ' <div class= " form-group " ><label class= " form-label " > { label } </label> '
2026-05-25 02:51:38 -04:00
f ' { inp } { dyn_hint_html } { hint_html } </div> ' )
2026-05-17 03:26:01 -04:00
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> ' )
2026-05-20 04:06:50 -04:00
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 ' ) ,
}
rows_html = ' '
cur_sc , cur_st = ' badge-disabled ' , ' '
cur_speed = cur_mtu = cur_mac = cur_perm_mac = cur_min_mtu = cur_max_mtu = None
try :
speed_pad = int ( tokens . get ( ' NETWORK_INTERFACE_STATS_SPEED_PAD ' , ' 0 ' ) )
except Exception :
speed_pad = 0
def _pad_speed ( val ) :
s = val or ' - '
return ' ' * max ( 0 , speed_pad - len ( s ) ) + e ( s )
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 ' )
speed = raw_speed or ' - '
mtu = raw_mtu or ' - '
mac = raw_mac or ' - '
sc , st = state_map . get ( state , ( ' badge-disabled ' , state . title ( ) ) )
type_txt = ' Wireless ' if wireless else ' Wired '
if wireless :
carrier_txt = ' - '
else :
carrier_txt = ' Yes ' if carrier else ( ' No ' if carrier is False else ' - ' )
sel_cls = ' selected ' if iname == current else ' '
if iname == current :
cur_sc , cur_st = sc , st
cur_speed , cur_mtu , cur_mac = raw_speed , raw_mtu , raw_mac
cur_perm_mac = ifc . get ( ' perm_mac ' )
cur_min_mtu = ifc . get ( ' min_mtu ' )
cur_max_mtu = ifc . get ( ' max_mtu ' )
padded_speed = _pad_speed ( raw_speed )
padded_mtu = ' ' * max ( 0 , 4 - len ( raw_mtu or ' - ' ) ) + e ( raw_mtu or ' - ' )
rows_html + = ( f ' <tr class= " iface-picker-row { sel_cls } " data-iface= " { e ( iname ) } " '
f ' data-state-class= " { e ( sc ) } " data-state-label= " { e ( st ) } " '
f ' data-speed= " { padded_speed } " data-mtu= " { padded_mtu } " '
f ' data-mac= " { e ( raw_mac or " " ) } " > '
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 ( speed ) } </td> '
f ' <td> { e ( mtu ) } </td> '
f ' <td class= " col-mono " > { e ( mac ) } </td> '
f ' </tr> ' )
table_html = ( f ' <div class= " table-wrapper " > '
f ' <table class= " data-table iface-picker-table " > '
f ' <thead><tr><th>Interface</th><th>Type</th><th>State</th> '
f ' <th>Carrier</th><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead> '
f ' <tbody> { rows_html } </tbody> '
f ' </table></div> ' )
btn_label = f ' <span class= " iface-picker-name " > { e ( current ) or " Select... " } </span> '
btn_badge = ( f ' <span class= " badge { cur_sc } iface-picker-badge " > { e ( cur_st ) } </span> '
if current else ' ' )
if current and any ( [ cur_speed , cur_mtu , cur_mac ] ) :
ext_meta = ( f ' <table class= " iface-picker-stats " > '
f ' <thead><tr><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead> '
f ' <tbody><tr> '
f ' <td> { _pad_speed ( cur_speed ) } </td> '
f ' <td> { " " * max ( 0 , 4 - len ( cur_mtu or " - " ) ) } { e ( cur_mtu or " - " ) } </td> '
f ' <td class= " col-mono " > { e ( cur_mac or " - " ) } </td> '
f ' </tr></tbody> '
f ' </table> ' )
else :
ext_meta = ' '
configure_btn = (
f ' <button type= " button " class= " btn btn-secondary iface-configure-btn " '
f ' data-iface= " { e ( current ) } " data-mtu= " { e ( cur_mtu or " " ) } " '
f ' data-mac= " { e ( cur_mac or " " ) } " data-perm-mac= " { e ( cur_perm_mac or " " ) } " '
f ' data-min-mtu= " { cur_min_mtu if cur_min_mtu is not None else " " } " '
f ' data-max-mtu= " { cur_max_mtu if cur_max_mtu is not None else " " } " > '
f ' Configure</button> '
) if current else ' '
return ( f ' <div class= " form-group " > '
f ' <label class= " form-label " > { label } </label> '
f ' <div class= " iface-picker " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " hidden " name= " { name } " value= " { e ( current ) } " /> '
2026-05-20 04:06:50 -04:00
f ' <div class= " iface-picker-header " > '
f ' <button type= " button " class= " iface-picker-btn " > { btn_label } { btn_badge } </button> '
f ' { ext_meta } '
f ' { configure_btn } '
f ' </div> '
f ' <div class= " iface-picker-dropdown " > { table_html } </div> '
f ' </div> '
f ' </div> ' )
validate = item . get ( ' validate ' , ' ' )
validate_attr = f ' data-validate= " { e ( validate ) } " ' if validate else ' '
2026-05-25 21:31:20 -04:00
dyn_hint = ' <p class= " form-hint field-dyn-hint hidden " ></p> ' if ( item . get ( ' readonly ' ) or item . get ( ' dyn_hint ' ) or validate ) else ' '
2026-05-17 03:26:01 -04:00
return ( f ' <div class= " form-group " ><label class= " form-label " > { label } </label> '
f ' <input type= " { e ( input_type ) } " name= " { name } " value= " { e ( value ) } " '
2026-05-24 00:08:14 -04:00
f ' placeholder= " { placeholder } " class= " form-input { extra_cls } " { readonly } { validate_attr } /> { hint_html } { dyn_hint } </div> ' )
2026-05-20 04:06:50 -04:00
def _collect_form_originals ( items , tokens ) :
""" Walk form items and return { name: value} for all input 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
2026-05-17 03:26:01 -04:00
def _render_editable_list ( item , tokens ) :
label = e ( item . get ( ' label ' , ' ' ) )
name = e ( item . get ( ' name ' , ' ' ) )
2026-05-20 04:06:50 -04:00
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 ) )
2026-05-17 03:26:01 -04:00
hint_html = f ' <p class= " form-hint " > { hint } </p> ' if hint else ' '
2026-05-20 04:06:50 -04:00
validate = e ( item . get ( ' validate ' , ' ' ) )
2026-05-17 03:26:01 -04:00
try :
items_list = json . loads ( apply_tokens ( item . get ( ' items ' , ' [] ' ) , tokens ) )
except Exception :
items_list = [ ]
rows = ' ' . join (
f ' <div class= " editable-list-item " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " text " name= " { name } " value= " { e ( v ) } " class= " form-input " /> '
2026-05-17 03:26:01 -04:00
f ' <button type= " button " class= " btn btn-ghost btn-sm editable-list-remove " >Remove</button> '
f ' </div> '
for v in items_list
)
2026-05-20 04:06:50 -04:00
validate_attr = f ' data-validate= " { validate } " ' if validate else ' '
2026-05-17 03:26:01 -04:00
return ( f ' <div class= " form-group " ><label class= " form-label " > { label } </label> '
2026-05-20 04:06:50 -04:00
f ' <div class= " editable-list " data-name= " { name } " data-placeholder= " { ph } " { validate_attr } > '
2026-05-17 03:26:01 -04:00
f ' { rows } '
f ' <button type= " button " class= " btn btn-ghost btn-sm editable-list-add " >+ { add_lbl } </button> '
f ' </div> { hint_html } </div> ' )
def _render_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 ' , [ ] )
2026-05-25 19:59:42 -04:00
hash_val = config_hash ( )
2026-05-17 03:26:01 -04:00
toolbar_html = ' '
toolbar = item . get ( ' toolbar ' )
if toolbar :
req = toolbar . get ( ' client_requirement ' , inherited_req )
if _passes ( req , level ) :
t_inner = render_items ( toolbar . get ( ' items ' , [ ] ) , tokens , req )
toolbar_html = f ' <div class= " table-toolbar " > { t_inner } </div> '
2026-05-20 04:06:50 -04:00
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
)
2026-05-17 03:26:01 -04:00
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 + = _render_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 ,
)
if row_actions :
btns = ' '
for ra in 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 ' :
2026-05-18 14:38:23 -04:00
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
2026-05-25 21:31:20 -04:00
btns + = ( f ' <form method= " post " action= " { action } " class= " form-inline " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " hidden " name= " row_index " value= " { idx } " /> '
f ' <input type= " hidden " name= " config_hash " value= " { e ( hash_val ) } " /> '
2026-05-17 03:26:01 -04:00
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> ' )
elif method == ' inline_edit ' :
fields_json = e ( json . dumps ( _expand_fields ( ra . get ( ' fields ' , [ ] ) , tokens ) ) )
row_json = e ( json . dumps ( row ) )
btns + = ( f ' <button type= " button " class= " btn { cls } row-inline-edit-btn " '
f ' data-row-index= " { idx } " data-row= " { row_json } " '
f ' data-action= " { action } " data-fields= " { fields_json } " > { 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 } '
f ' <div class= " table-wrapper " > '
f ' <table class= " data-table " > '
f ' <thead><tr> { thead } </tr></thead> '
f ' <tbody> { tbody } </tbody> '
f ' </table></div> ' )
def _render_table_cell ( value , render_fn , col_class = ' ' , field = ' ' , row_idx = None ,
toggle_action = None , toggle_allowed = True ) :
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> '
2026-05-25 22:27:04 -04:00
if render_fn == ' badge_yes_no ' :
if str ( value ) . lower ( ) in ( ' true ' , ' 1 ' , ' yes ' , ' enabled ' ) :
inner = ' <span class= " badge badge-enabled " >Yes</span> '
else :
inner = ' <span class= " badge badge-disabled " >No</span> '
return f ' { td_open } { inner } </td> '
2026-05-25 02:22:21 -04:00
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> '
2026-05-17 03:26:01 -04:00
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 :
2026-05-25 21:31:20 -04:00
inner = ( f ' <form method= " post " action= " { e ( toggle_action ) } " class= " form-inline " > '
2026-05-24 00:08:14 -04:00
f ' <input type= " hidden " name= " row_index " value= " { row_idx } " /> '
2026-05-17 03:26:01 -04:00
f ' <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> '
2026-05-23 23:55:58 -04:00
if render_fn == ' raw_html ' :
return f ' { td_open } { value } </td> '
2026-05-17 03:26:01 -04:00
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 ]
2026-05-18 20:02:22 -04:00
def _tag ( t ) :
if isinstance ( t , dict ) :
s , tooltip = str ( t . get ( ' n ' , ' ' ) ) . strip ( ) , str ( t . get ( ' d ' , t . get ( ' n ' , ' ' ) ) ) . strip ( )
else :
s = tooltip = str ( t ) . strip ( )
if not s :
return ' '
short = s . split ( ' - ' ) [ 0 ]
mini = s [ 0 ]
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> '
f ' </span> ' )
tags = ' ' . join ( _tag ( t ) for t in items )
2026-05-17 03:26:01 -04:00
return f ' { td_open } <div class= " tag-list " > { tags } </div></td> '
2026-05-18 14:38:23 -04:00
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> '
2026-05-17 03:26:01 -04:00
return f ' { td_open } { e ( value ) } </td> '
def _load_datasource ( spec ) :
if spec . startswith ( ' live: ' ) :
name = spec [ 5 : ]
if name == ' dhcp_leases ' : return _live_dhcp_leases ( )
if name == ' vpn_sessions ' : return _live_vpn_sessions ( )
return [ ]
if spec . startswith ( ' config: ' ) :
return _config_datasource ( spec [ 7 : ] )
return [ ]
2026-05-21 03:45:14 -04:00
# Layout renderer ===================================================
2026-05-17 03:26:01 -04:00
def render_layout ( view_id , content_html , tokens ) :
css = _load_css ( )
level = _client_level ( )
2026-05-26 00:07:35 -04:00
has_pending_alert = not _apply_changes_immediately ( ) and bool ( get_dashboard_pending ( ) )
2026-05-24 01:46:48 -04:00
titlebar_html = f ' <div class= " titlebar " ><span class= " titlebar-brand " > { WEB_APP_DISPLAY_NAME } </span></div> '
2026-05-26 00:07:35 -04:00
navbar_html = _render_navbar ( view_id , level , tokens , pending_alert = has_pending_alert )
2026-05-24 01:46:48 -04:00
footer_html = f ' <footer class= " footer " > { WEB_APP_DISPLAY_NAME } </footer> '
2026-05-17 03:26:01 -04:00
2026-05-25 19:59:42 -04:00
page_hash = config_hash ( )
2026-05-18 14:38:23 -04:00
lan_iface = e ( tokens . get ( ' GENERAL_LAN_INTERFACE ' , ' ' ) )
vpn_count = tokens . get ( ' VPN_VLAN_COUNT ' , ' 0 ' )
existing_ids = tokens . get ( ' EXISTING_VLAN_IDS_JSON ' , ' [] ' )
existing_names = tokens . get ( ' EXISTING_VLAN_NAMES_JSON ' , ' [] ' )
existing_interfaces = tokens . get ( ' EXISTING_VLAN_INTERFACES_JSON ' , ' [] ' )
2026-05-20 04:06:50 -04:00
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 ) , 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 )
if locked and lock_mtime and o_ts < lock_mtime :
text = f ' { e ( o_user ) } \' s changes are being applied now... '
cls = ' info-bar-warning info-bar-running '
else :
timing = _format_timing ( secs )
2026-05-25 23:57:34 -04:00
text = f ' { e ( o_user ) } has pending changes which will be applied { timing } . ' if timing else f ' { e ( o_user ) } has pending changes. The processing service is not running. '
2026-05-20 04:06:50 -04:00
cls = ' info-bar-warning '
2026-05-26 00:12:42 -04:00
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 '
2026-05-20 04:06:50 -04:00
2026-05-22 01:09:23 -04:00
problem_bars = ' '
try :
import json as _j
2026-05-25 01:04:47 -04:00
st = _j . load ( open ( f ' { CONFIGS_DIR } /.health ' ) )
2026-05-24 00:47:43 -04:00
grouped = { ' error ' : [ ] , ' warning ' : [ ] }
2026-05-22 01:09:23 -04:00
for section in ( ' configurations ' , ' logs ' ) :
for item in st . get ( section , [ ] ) :
if item . get ( ' status ' ) == ' problem ' :
sev = item . get ( ' severity ' , ' error ' )
text = e ( item . get ( ' detail ' , item . get ( ' name ' , ' ' ) ) )
2026-05-25 23:57:34 -04:00
grouped . setdefault ( sev , [ ] ) . append ( text )
2026-05-24 00:59:15 -04:00
for item in st . get ( ' services ' , [ ] ) :
if item . get ( ' status ' ) == ' problem ' :
2026-05-24 01:13:45 -04:00
name = item . get ( ' name ' , ' ' )
utype = ' timer ' if name . endswith ( ' .timer ' ) else ' service ' if name . endswith ( ' .service ' ) else ' unit '
2026-05-25 23:57:34 -04:00
exp_parts , act_parts = [ ] , [ ]
2026-05-24 00:59:15 -04:00
if not item . get ( ' active_ok ' ) :
2026-05-24 01:13:45 -04:00
exp_parts . append ( item . get ( ' expected_active ' , ' active ' ) )
act_parts . append ( item . get ( ' active ' , ' unknown ' ) )
2026-05-24 00:59:15 -04:00
if not item . get ( ' enabled_ok ' ) :
2026-05-24 01:13:45 -04:00
exp_parts . append ( item . get ( ' expected_enabled ' , ' enabled ' ) )
act_parts . append ( item . get ( ' enabled ' , ' unknown ' ) )
detail = ( f " The { utype } ` { name } ` is expected to be "
f " { ' and ' . join ( exp_parts ) } but is { ' and ' . join ( act_parts ) } . " )
2026-05-25 23:57:34 -04:00
grouped . setdefault ( item . get ( ' severity ' , ' error ' ) , [ ] ) . append ( e ( detail ) )
has_problems = any ( items for items in grouped . values ( ) )
fix_suffix = ' '
if has_problems :
fix_uuid , fix_ts = queue_command ( ' core apply ' )
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 :
2026-05-26 00:12:42 -04:00
fix_suffix = ' Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix. '
2026-05-24 00:47:43 -04:00
for sev , items in grouped . items ( ) :
if not items :
continue
cls = ' info-bar-danger ' if sev == ' error ' else ' info-bar-warning '
2026-05-24 01:23:46 -04:00
problems_list = ( ' <ul style= " margin:0.25em 0;padding-left:1.25em " > '
2026-05-25 23:57:34 -04:00
+ ' ' . join ( f ' <li> { d } </li> ' for d in items )
2026-05-24 01:23:46 -04:00
+ ' </ul> ' )
2026-05-26 00:00:36 -04:00
uuid_attr = f ' data-health-uuid= " { e ( fix_uuid ) } " ' if _apply_changes_immediately ( ) else ' '
2026-05-26 00:07:35 -04:00
fix_html = ( f ' <div style= " margin-top:0.5em " { uuid_attr } > { fix_suffix } </div> '
2026-05-25 23:57:34 -04:00
if fix_suffix else ' ' )
2026-05-24 01:46:48 -04:00
content = ( ' <div style= " width:100 % " > '
2026-05-24 01:51:34 -04:00
' <div style= " font-weight:600;margin-bottom:0.25em " >Health check - problems found:</div> '
2026-05-24 01:46:48 -04:00
+ problems_list + fix_html
+ ' </div> ' )
2026-05-24 00:47:43 -04:00
problem_bars + = f ' <div class= " info-bar { cls } " > { content } </div> \n '
2026-05-22 01:09:23 -04:00
except Exception :
pass
2026-05-26 00:07:35 -04:00
pending_bar = ' '
2026-05-26 00:14:50 -04:00
if has_pending_alert and not problem_bars and view_id != ' view_actions ' :
2026-05-26 00:07:35 -04:00
pending_bar = ( ' <div class= " info-bar info-bar-warning " > '
2026-05-26 00:12:42 -04:00
' <span>You have actions pending. Please visit the <strong>Actions</strong> page.</span> '
2026-05-26 00:07:35 -04:00
' </div> \n ' )
2026-05-17 03:26:01 -04:00
return ( f ' <!DOCTYPE html> \n <html lang= " en " > \n <head> \n '
2026-05-24 00:08:14 -04:00
f ' <meta charset= " UTF-8 " /> \n '
f ' <meta name= " viewport " content= " width=device-width, initial-scale=1.0 " /> \n '
2026-05-24 01:46:48 -04:00
f ' <title> { WEB_APP_DISPLAY_NAME } </title> \n '
2026-05-17 03:26:01 -04:00
f ' <style> { css } </style> \n '
f ' </head> \n <body> \n '
f ' { titlebar_html } \n '
f ' { navbar_html } \n '
2026-05-26 00:07:35 -04:00
f ' <main class= " main-content " > \n { pending_bar } { problem_bars } { other_bars } { content_html } \n </main> \n '
2026-05-17 03:26:01 -04:00
f ' { footer_html } \n '
2026-05-20 04:06:50 -04:00
f ' <script>var CONFIG_HASH= " { page_hash } " ;var LAN_IFACE= " { lan_iface } " ;var VPN_VLAN_COUNT= { vpn_count } ;var EXISTING_VLAN_IDS= { existing_ids } ;var EXISTING_VLAN_NAMES= { existing_names } ;var EXISTING_VLAN_INTERFACES= { existing_interfaces } ;var APPLY_UUID= { json . dumps ( my_uuid ) } ;</script> \n '
2026-05-17 03:26:01 -04:00
f ' <script> { _inline_js ( ) } </script> \n '
f ' </body> \n </html> ' )
2026-05-26 00:07:35 -04:00
def _render_navbar ( active_view , level , tokens , pending_alert = False ) :
2026-05-17 03:26:01 -04:00
navbar_data = _load_json ( f ' { DATA_DIR } /navbar_content.json ' )
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
2026-05-26 00:07:35 -04:00
frag = _render_nav_item ( item , active_view , level , in_dropdown = False , inherited_req = req , pending_alert = pending_alert )
2026-05-17 03:26:01 -04:00
( right if align == ' right ' else left ) . append ( frag )
return ( f ' <nav class= " nav-bar " > '
f ' <div class= " nav-left " > { " " . join ( left ) } </div> '
f ' <div class= " nav-right " > { " " . join ( right ) } </div> '
f ' </nav> ' )
2026-05-26 00:07:35 -04:00
def _render_nav_item ( item , active_view , level , in_dropdown = False , inherited_req = None , pending_alert = False ) :
2026-05-17 03:26:01 -04:00
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 ' '
2026-05-26 00:07:35 -04:00
pending = ' nav-item-pending ' if pending_alert and map_to == ' view_actions ' else ' '
cls = f ' dropdown-item { is_active } ' if in_dropdown else f ' nav-item { is_active } { pending } '
2026-05-17 03:26:01 -04:00
if action :
2026-05-25 21:31:20 -04:00
return ( f ' <form method= " post " action= " /action/ { e ( action ) } " class= " form-inline " > '
2026-05-17 03:26:01 -04:00
f ' <button type= " submit " class= " { cls } " > { label } </button></form> ' )
if map_to :
return f ' <a href= " /view/ { 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 = ' Configure ' if level > = LEVEL_RANK [ ' administrator ' ] else ' View '
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
2026-05-26 00:07:35 -04:00
children + = _render_nav_item ( child , active_view , level , in_dropdown = True , inherited_req = req , pending_alert = pending_alert )
2026-05-17 03:26:01 -04:00
if not children :
return ' '
return ( f ' <div class= " nav-menu " > '
f ' <button class= " nav-item nav-menu-trigger " aria-haspopup= " true " > { label } </button> '
f ' <div class= " nav-dropdown " > { children } </div> '
f ' </div> ' )
return ' '
2026-05-21 03:45:14 -04:00
# Inline JavaScript =================================================
2026-05-17 03:26:01 -04:00
def _inline_js ( ) :
return r """
2026-05-23 00:37:35 -04:00
function showCard ( el ) {
el . style . display = ' ' ;
el . classList . remove ( ' card-reveal ' ) ;
void el . offsetWidth ;
el . classList . add ( ' card-reveal ' ) ;
el . addEventListener ( ' animationend ' , function ( ) { el . classList . remove ( ' card-reveal ' ) ; } , { once : true } ) ;
}
2026-05-18 14:38:23 -04:00
function prefixToDotted ( n ) {
if ( n < 1 | | n > 30 ) return ' ' ;
var mask = ( ( 0xFFFFFFFF << ( 32 - n ) ) >> > 0 ) ;
return [ ( mask >> > 24 ) & 0xFF , ( mask >> > 16 ) & 0xFF , ( mask >> > 8 ) & 0xFF , mask & 0xFF ] . join ( ' . ' ) ;
}
function deriveVlanId ( subnet , prefix ) {
var parts = subnet . split ( ' . ' ) ;
if ( parts . length != = 4 ) return null ;
var octets = parts . map ( function ( p ) { return parseInt ( p , 10 ) ; } ) ;
if ( octets . some ( function ( o ) { return isNaN ( o ) | | o < 0 | | o > 255 ; } ) ) return null ;
var byteIdx = Math . floor ( ( prefix - 1 ) / 8 ) ;
var id = octets [ byteIdx ] ;
return ( id > = 0 & & id < = 4094 ) ? id : null ;
}
2026-05-18 20:02:22 -04:00
function networkBitsMessage ( octets , prefix ) {
var byteIdx = Math . floor ( ( prefix - 1 ) / 8 ) ;
var hostBitsInActive = ( prefix % 8 == = 0 ) ? 0 : ( 8 - ( prefix % 8 ) ) ;
var activeMask = hostBitsInActive == = 0 ? 0xFF : ( ( 0xFF << hostBitsInActive ) & 0xFF ) ;
var ordinals = [ ' 1st ' , ' 2nd ' , ' 3rd ' , ' 4th ' ] ;
var parts = [ ] ;
if ( hostBitsInActive > 0 & & ( octets [ byteIdx ] & ~ activeMask ) != = 0 ) {
var step = 1 << hostBitsInActive ;
var vals = [ ] ;
for ( var v = 0 ; v < 256 ; v + = step ) vals . push ( String ( v ) ) ;
var valStr = vals . length < = 8
? vals . slice ( 0 , - 1 ) . join ( ' , ' ) + ' or ' + vals [ vals . length - 1 ]
: ' a multiple of ' + step ;
parts . push ( ordinals [ byteIdx ] + ' quartet must be ' + valStr ) ;
}
var badTrailing = [ ] ;
for ( var i = byteIdx + 1 ; i < 4 ; i + + ) {
if ( octets [ i ] != = 0 ) badTrailing . push ( ordinals [ i ] ) ;
}
if ( badTrailing . length > 0 ) {
var nameStr = badTrailing . length == = 1
? badTrailing [ 0 ]
: badTrailing . slice ( 0 , - 1 ) . join ( ' , ' ) + ' and ' + badTrailing [ badTrailing . length - 1 ] ;
parts . push ( nameStr + ' quartet ' + ( badTrailing . length > 1 ? ' s ' : ' ' ) + ' must be 0 ' ) ;
}
if ( parts . length == = 0 ) return null ;
return parts . join ( ' ; ' ) + ' for / ' + prefix ;
}
2026-05-20 04:06:50 -04:00
function classifyMac ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ 0 - 9 a - fA - F : ] / . test ( s ) ) return ' invalid_char ' ;
if ( / : : / . test ( s ) ) return ' invalid_struct ' ;
var groups = s . split ( ' : ' ) ;
if ( groups . length > 6 ) return ' too_many ' ;
for ( var i = 0 ; i < groups . length ; i + + ) {
if ( groups [ i ] . length > 2 ) return ' invalid_group ' ;
}
if ( groups . length == = 6 & & groups . every ( function ( g ) { return g . length == = 2 ; } ) ) return ' complete ' ;
return ' incomplete ' ;
}
function classifyIp ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ 0 - 9 a - fA - F : . ] / . test ( s ) ) return ' invalid_char ' ;
if ( s . indexOf ( ' : ' ) != = - 1 ) {
/ / IPv6
if ( / : : : / . test ( s ) | | ( s . match ( / : : / g ) | | [ ] ) . length > 1 ) return ' invalid_struct ' ;
var v6parts = s . split ( ' : ' ) . filter ( function ( p ) { return p != = ' ' ; } ) ;
if ( ! v6parts . every ( function ( p ) { return / ^ [ 0 - 9 a - fA - F ] { 1 , 4 } $ / . test ( p ) | | / ^ \d { 1 , 3 } \. \d { 1 , 3 } \. \d { 1 , 3 } \. \d { 1 , 3 } $ / . test ( p ) ; } ) ) return ' invalid ' ;
var fullGroups = s . replace ( / [ ^ : ] / g , ' ' ) . length ;
if ( s . indexOf ( ' :: ' ) != = - 1 | | fullGroups == = 7 ) return ' complete ' ;
return ' incomplete ' ;
}
/ / IPv4
if ( / \. \. / . test ( s ) | | s . charAt ( 0 ) == = ' . ' ) return ' invalid_struct ' ;
var parts = s . split ( ' . ' ) ;
if ( parts . length > 4 ) return ' invalid_struct ' ;
for ( var i = 0 ; i < parts . length ; i + + ) {
if ( ! parts [ i ] ) continue ;
var n = parseInt ( parts [ i ] , 10 ) ;
if ( isNaN ( n ) | | n > 255 | | String ( n ) != = parts [ i ] ) return ' invalid_range ' ;
}
if ( parts . length == = 4 & & parts . every ( function ( p ) { return p != = ' ' ; } ) ) return ' complete ' ;
return ' incomplete ' ;
}
function classifyIpv4 ( s ) {
if ( ! s ) return ' empty ' ;
if ( s . indexOf ( ' : ' ) != = - 1 ) return ' invalid_struct ' ;
return classifyIp ( s ) ;
}
function classifyIpv6 ( s ) {
if ( ! s ) return ' empty ' ;
if ( s . indexOf ( ' . ' ) != = - 1 & & s . indexOf ( ' : ' ) == = - 1 ) return ' invalid_struct ' ;
if ( s . indexOf ( ' : ' ) == = - 1 ) return ' incomplete ' ;
return classifyIp ( s ) ;
}
function classifyUrl ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ A - Za - z0 - 9 \- . _ ~ : / ? #\[\]@!$&'()*+,;=%]/.test(s)) return 'invalid_char';
var sl = s . toLowerCase ( ) ;
if ( ' https:// ' . startsWith ( sl ) | | ' http:// ' . startsWith ( sl ) ) return ' incomplete ' ;
var sep = sl . indexOf ( ' :// ' ) ;
if ( sep == = - 1 ) return ' invalid_struct ' ;
var scheme = sl . slice ( 0 , sep ) ;
if ( scheme != = ' http ' & & scheme != = ' https ' ) return ' invalid_struct ' ;
var afterScheme = s . slice ( sep + 3 ) ;
if ( ! afterScheme ) return ' incomplete ' ;
var hostEnd = afterScheme . search ( / [ / : ? #]/);
var host = hostEnd == = - 1 ? afterScheme : afterScheme . slice ( 0 , hostEnd ) ;
var rest = hostEnd == = - 1 ? ' ' : afterScheme . slice ( hostEnd ) ;
if ( ! host ) return ' incomplete ' ;
if ( / \. \. / . test ( host ) | | host . charAt ( 0 ) == = ' . ' | | host . charAt ( host . length - 1 ) == = ' . ' ) return ' invalid_struct ' ;
var labels = host . split ( ' . ' ) ;
for ( var i = 0 ; i < labels . length ; i + + ) {
if ( ! / ^ [ a - zA - Z0 - 9 ] ( [ a - zA - Z0 - 9 \- ] * [ a - zA - Z0 - 9 ] ) ? $ / . test ( labels [ i ] ) ) return ' invalid_struct ' ;
}
if ( rest . charAt ( 0 ) == = ' : ' ) {
var portMatch = rest . slice ( 1 ) . match ( / ^ \d + / ) ;
if ( ! portMatch ) return ' incomplete ' ;
if ( parseInt ( portMatch [ 0 ] ) < 1 | | parseInt ( portMatch [ 0 ] ) > 65535 ) return ' invalid_struct ' ;
}
return ' complete ' ;
}
function classifyPort ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ 0 - 9 ] / . test ( s ) ) return ' invalid_char ' ;
var n = parseInt ( s , 10 ) ;
if ( n < 1 | | n > 65535 ) return ' out_of_range ' ;
return ' complete ' ;
}
function classifyIpv4Cidr ( s ) {
if ( ! s ) return ' empty ' ;
var slash = s . indexOf ( ' / ' ) ;
if ( slash == = - 1 ) return classifyIpv4 ( s ) ;
var ipCls = classifyIpv4 ( s . slice ( 0 , slash ) ) ;
if ( ipCls != = ' complete ' ) return ipCls ;
var prefix = s . slice ( slash + 1 ) ;
if ( ! prefix ) return ' incomplete ' ;
if ( / [ ^ 0 - 9 ] / . test ( prefix ) ) return ' invalid_char ' ;
var n = parseInt ( prefix , 10 ) ;
if ( n < 0 | | n > 32 ) return ' invalid_struct ' ;
return ' complete ' ;
}
function classifyEndpoint ( s ) {
if ( ! s ) return ' empty ' ;
if ( s . indexOf ( ' : ' ) != = - 1 ) return classifyIp ( s ) ;
if ( / ^ [ 0 - 9. ] + $ / . test ( s ) ) return classifyIp ( s ) ;
return classifyDomainname ( s ) ;
}
function classifyDashname ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ a - z0 - 9 - ] / . test ( s ) ) return ' invalid_char ' ;
if ( s . charAt ( 0 ) == = ' - ' ) return ' invalid_struct ' ;
if ( / - - / . test ( s ) ) return ' invalid_struct ' ;
if ( s . charAt ( s . length - 1 ) == = ' - ' ) return ' incomplete ' ;
return ' complete ' ;
}
function classifyDomainname ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ a - zA - Z0 - 9. - ] / . test ( s ) ) return ' invalid_char ' ;
if ( s . charAt ( 0 ) == = ' . ' ) return ' invalid_struct ' ;
if ( / \. \. / . test ( s ) ) return ' invalid_struct ' ;
if ( s . charAt ( s . length - 1 ) == = ' . ' ) return ' incomplete ' ;
var labels = s . split ( ' . ' ) ;
for ( var i = 0 ; i < labels . length ; i + + ) {
var l = labels [ i ] ;
if ( l . charAt ( 0 ) == = ' - ' | | l . charAt ( l . length - 1 ) == = ' - ' ) return ' invalid_struct ' ;
}
return ' complete ' ;
}
function classifyNetworkname ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ a - zA - Z0 - 9 _ - ] / . test ( s ) ) return ' invalid_char ' ;
if ( s . charAt ( 0 ) == = ' - ' | | s . charAt ( 0 ) == = ' _ ' ) return ' invalid_struct ' ;
if ( / [ - _ ] { 2 , } / . test ( s ) ) return ' invalid_struct ' ;
if ( s . charAt ( s . length - 1 ) == = ' - ' | | s . charAt ( s . length - 1 ) == = ' _ ' ) return ' incomplete ' ;
return ' complete ' ;
}
2026-05-23 03:11:14 -04:00
function classifyTime24h ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ 0 - 9 : ] / . test ( s ) ) return ' invalid_char ' ;
if ( s . length < 5 ) return ' incomplete ' ;
if ( ! / ^ ( [ 01 ] \d | 2 [ 0 - 3 ] ) : [ 0 - 5 ] \d $ / . test ( s ) ) return ' invalid_struct ' ;
return ' complete ' ;
}
2026-05-25 02:51:38 -04:00
function classifyPositiveInt ( s , el ) {
if ( el & & el . validity & & el . validity . badInput ) return ' invalid_char ' ;
if ( ! s & & s != = ' 0 ' ) return ' empty ' ;
if ( / [ ^ 0 - 9 ] / . test ( s ) ) return ' invalid_char ' ;
var n = parseInt ( s , 10 ) ;
var min = ( el & & el . min != = ' ' ) ? parseInt ( el . min , 10 ) : 0 ;
var max = ( el & & el . max != = ' ' ) ? parseInt ( el . max , 10 ) : null ;
if ( n < min | | ( max != = null & & n > max ) ) return ' out_of_range ' ;
return ' complete ' ;
}
2026-05-18 14:38:23 -04:00
function classifySubnet ( s ) {
if ( ! s ) return ' empty ' ;
if ( / [ ^ 0 - 9. ] / . test ( s ) ) return ' invalid_char ' ;
if ( / \. \. / . test ( s ) | | s . charAt ( 0 ) == = ' . ' ) return ' invalid_struct ' ;
var parts = s . split ( ' . ' ) ;
if ( parts . length > 4 ) return ' too_many ' ;
for ( var i = 0 ; i < parts . length ; i + + ) {
var p = parts [ i ] ;
if ( ! p ) continue ;
var n = parseInt ( p , 10 ) ;
if ( isNaN ( n ) | | n > 255 ) return ' range ' ;
}
if ( parts . length < 4 | | parts [ 3 ] == = ' ' ) return ' incomplete ' ;
return ' complete ' ;
}
function setFieldHint ( input , message , state ) {
/ / state : ' error ' | ' warning ' | ' ok '
var fg = input . closest ( ' .form-group ' ) ;
2026-05-20 04:06:50 -04:00
var hintContainer = fg | | input . parentElement ;
if ( hintContainer ) {
var hint = hintContainer . querySelector ( ' .field-dyn-hint ' ) ;
2026-05-18 14:38:23 -04:00
if ( hint ) {
hint . textContent = message ;
hint . style . display = message ? ' ' : ' none ' ;
hint . style . color = ( state == = ' error ' ) ? ' var(--danger) ' : ' var(--text-muted) ' ;
}
}
input . classList . remove ( ' field-invalid ' , ' field-warning ' ) ;
if ( state == = ' error ' & & message ) input . classList . add ( ' field-invalid ' ) ;
else if ( state == = ' warning ' ) input . classList . add ( ' field-warning ' ) ;
}
function updateAddVlanForm ( form ) {
var nameInp = form . querySelector ( ' input[name= " name " ] ' ) ;
var subnetInp = form . querySelector ( ' input[name= " subnet " ] ' ) ;
var prefixInp = form . querySelector ( ' input.subnet-prefix-input ' ) ;
var vpnChk = form . querySelector ( ' input[name= " is_vpn " ] ' ) ;
var ifacePrev = form . querySelector ( ' .vlan-iface-preview ' ) ;
var derivedPrev = form . querySelector ( ' .vlan-derived-id-preview ' ) ;
var submitBtn = form . querySelector ( ' .add-vlan-btn ' ) ;
if ( ! subnetInp | | ! prefixInp ) return ;
var subnet = subnetInp . value . trim ( ) ;
var prefix = parseInt ( prefixInp . value , 10 ) ;
var isVpn = vpnChk & & vpnChk . checked ;
var lan = typeof LAN_IFACE != = ' undefined ' ? LAN_IFACE : ' eth0 ' ;
var sClass = classifySubnet ( subnet ) ;
var id = ( sClass == = ' complete ' ) ? deriveVlanId ( subnet , prefix ) : null ;
/ / Derived VLAN ID preview
if ( derivedPrev ) derivedPrev . value = ( id != = null ) ? String ( id ) : ' ' ;
/ / Interface preview
var ifaceVal = ' ' ;
if ( isVpn ) {
ifaceVal = ' wg ' + ( typeof VPN_VLAN_COUNT != = ' undefined ' ? VPN_VLAN_COUNT : 0 ) ;
} else if ( id != = null ) {
ifaceVal = ( id == = 1 ) ? lan : lan + ' . ' + id ;
}
if ( ifacePrev ) ifacePrev . value = ifaceVal ;
/ / Subnet sub - text + colour
var subnetMsg = ' ' , subnetState = ' ok ' , subnetOk = false ;
if ( sClass == = ' empty ' | | sClass == = ' incomplete ' ) {
subnetState = ' warning ' ;
} else if ( sClass == = ' invalid_char ' | | sClass == = ' invalid_struct ' | | sClass == = ' too_many ' ) {
subnetMsg = ' Invalid ' ; subnetState = ' error ' ;
} else if ( sClass == = ' range ' ) {
subnetMsg = ' Quartet out of range ' ; subnetState = ' error ' ;
} else {
2026-05-18 20:02:22 -04:00
var octetsArr = subnet . split ( ' . ' ) . map ( Number ) ;
var hostMsg = networkBitsMessage ( octetsArr , prefix ) ;
if ( hostMsg ) {
subnetMsg = hostMsg ; subnetState = ' error ' ;
} else if ( id == = 0 ) {
subnetMsg = ' Would compute to VLAN ID 0 (reserved) ' ; subnetState = ' error ' ;
2026-05-18 14:38:23 -04:00
} else if ( id == = null | | EXISTING_VLAN_IDS . indexOf ( id ) != = - 1 ) {
subnetMsg = id == = null ? ' ' : ' Duplicate ' ; subnetState = id == = null ? ' warning ' : ' error ' ;
} else {
subnetOk = true ;
}
}
setFieldHint ( subnetInp , subnetMsg , subnetState ) ;
/ / Interface duplicate / reserved sub - text
if ( ifacePrev ) {
2026-05-18 20:02:22 -04:00
if ( id == = 0 & & ! isVpn ) {
2026-05-18 14:38:23 -04:00
setFieldHint ( ifacePrev , ' Reserved ' , ' error ' ) ;
} else {
var ifaceDupe = ifaceVal . length > 0 & & EXISTING_VLAN_INTERFACES . indexOf ( ifaceVal ) != = - 1 ;
setFieldHint ( ifacePrev , ifaceDupe ? ' Duplicate ' : ' ' , ifaceDupe ? ' error ' : ' ok ' ) ;
}
}
/ / VLAN ID duplicate / reserved sub - text
if ( derivedPrev ) {
if ( id == = 0 ) {
setFieldHint ( derivedPrev , ' Reserved ' , ' error ' ) ;
} else {
var derivedDupe = id != = null & & EXISTING_VLAN_IDS . indexOf ( id ) != = - 1 ;
setFieldHint ( derivedPrev , derivedDupe ? ' Duplicate ' : ' ' , derivedDupe ? ' error ' : ' ok ' ) ;
}
}
/ / Name validation + colour
if ( submitBtn ) {
var name = nameInp ? nameInp . value . trim ( ) . toLowerCase ( ) : ' ' ;
var nameValid = name . length > 0 & & / ^ [ a - z0 - 9 - ] + $ / . test ( name ) ;
var nameDupe = nameValid & & EXISTING_VLAN_NAMES . indexOf ( name ) != = - 1 ;
var nameOk = nameValid & & ! nameDupe ;
if ( nameInp ) {
nameInp . classList . remove ( ' field-invalid ' , ' field-warning ' ) ;
if ( name . length == = 0 ) nameInp . classList . add ( ' field-warning ' ) ;
else if ( ! nameOk ) nameInp . classList . add ( ' field-invalid ' ) ;
}
submitBtn . disabled = ! ( nameOk & & subnetOk ) ;
}
}
document . addEventListener ( ' input ' , function ( e ) {
var wrap = e . target . closest ( ' .subnet-row-wrap ' ) ;
if ( wrap ) {
var dotLabel = wrap . querySelector ( ' .subnet-dotted ' ) ;
if ( dotLabel ) {
var n = parseInt ( wrap . querySelector ( ' .subnet-prefix-input ' ) . value , 10 ) ;
dotLabel . textContent = ( n > = 1 & & n < = 30 ) ? prefixToDotted ( n ) : ' ' ;
}
}
var form = e . target . closest ( ' form ' ) ;
if ( form & & form . querySelector ( ' .add-vlan-btn ' ) ) updateAddVlanForm ( form ) ;
} ) ;
document . addEventListener ( ' change ' , function ( e ) {
if ( e . target . name != = ' is_vpn ' ) return ;
var form = e . target . closest ( ' form ' ) ;
if ( form & & form . querySelector ( ' .add-vlan-btn ' ) ) updateAddVlanForm ( form ) ;
} ) ;
2026-05-17 03:26:01 -04:00
document . querySelectorAll ( ' .row-edit-btn ' ) . forEach ( function ( btn ) {
btn . addEventListener ( ' click ' , function ( ) {
var row = JSON . parse ( this . dataset . row ) ;
var idx = this . dataset . rowIndex ;
var target = document . getElementById ( this . dataset . target ) ;
if ( ! target ) return ;
var idxField = target . querySelector ( ' [name= " row_index " ] ' ) ;
if ( idxField ) idxField . value = idx ;
Object . keys ( row ) . forEach ( function ( key ) {
var field = target . querySelector ( ' [name= " ' + key + ' " ] ' ) ;
if ( ! field ) return ;
if ( field . type == = ' checkbox ' ) {
field . checked = row [ key ] == = true | | row [ key ] == = ' true ' | | row [ key ] == = 1 ;
} else {
field . value = row [ key ] != null ? String ( row [ key ] ) : ' ' ;
}
} ) ;
2026-05-23 00:37:35 -04:00
showCard ( target ) ;
2026-05-17 03:26:01 -04:00
target . scrollIntoView ( { behavior : ' smooth ' , block : ' nearest ' } ) ;
} ) ;
} ) ;
document . addEventListener ( ' click ' , function ( e ) {
var btn = e . target . closest ( ' .row-inline-edit-btn ' ) ;
if ( ! btn ) return ;
var rowData = JSON . parse ( btn . dataset . row ) ;
var idx = btn . dataset . rowIndex ;
var action = btn . dataset . action ;
var fields = JSON . parse ( btn . dataset . fields ) ;
var tr = btn . closest ( ' tr ' ) ;
var fieldMap = { } ;
fields . forEach ( function ( f ) { fieldMap [ f . col ] = f ; } ) ;
function esc ( s ) {
return String ( s ) . replace ( / & / g , ' & ' ) . replace ( / " /g, ' " ' ).replace(/</g, ' < ' ).replace(/>/g, ' > ' );
}
function buildCredentialsHtml ( provider , data ) {
if ( provider == = ' noip ' ) {
return ' <div class= " cred-field " ><span class= " cred-label " >U:</span> ' +
' <input type= " text " name= " username " value= " ' + esc ( data . username | | ' ' ) +
2026-05-24 00:08:14 -04:00
' " class= " form-input inline-edit-input " /></div> ' +
2026-05-17 03:26:01 -04:00
' <div class= " cred-field " ><span class= " cred-label " >P:</span> ' +
' <input type= " password " name= " password " value= " ' + esc ( data . password | | ' ' ) +
2026-05-24 00:08:14 -04:00
' " class= " form-input inline-edit-input " /></div> ' ;
2026-05-17 03:26:01 -04:00
} else {
return ' <input type= " text " name= " api_token " value= " ' + esc ( data . api_token | | ' ' ) +
2026-05-24 00:08:14 -04:00
' " class= " form-input inline-edit-input " placeholder= " API Token " /> ' ;
2026-05-17 03:26:01 -04:00
}
}
tr . querySelectorAll ( ' td[data-field] ' ) . forEach ( function ( td ) {
var field = td . dataset . field ;
td . dataset . orig = td . innerHTML ;
var fDef = fieldMap [ field ] ;
if ( fDef == = undefined ) return ;
var inputType = fDef . input_type | | ' text ' ;
var val = rowData [ field ] != null ? rowData [ field ] : ' ' ;
if ( inputType == = ' checkbox ' ) {
var checked = ( val == = true | | val == = ' true ' | | val == = 1 | | val == = ' 1 ' ) ;
2026-05-25 02:51:38 -04:00
var cbLabel = fDef . checkbox_label ? ' <span class= " form-checkbox-label " > ' + esc ( fDef . checkbox_label ) + ' </span> ' : ' ' ;
td . innerHTML = ' <label class= " inline-edit-checkbox-wrap " > ' +
' <input type= " checkbox " name= " ' + field + ' " ' +
( checked ? ' checked ' : ' ' ) + ' class= " inline-edit-checkbox " /> ' + cbLabel + ' </label> ' ;
2026-05-18 14:38:23 -04:00
} else if ( inputType == = ' checkbox_multi ' ) {
var opts = fDef . options | | [ ] ;
var checked = [ ] ;
try { var parsed = JSON . parse ( val ) ; if ( Array . isArray ( parsed ) ) checked = parsed ; } catch ( ex ) { }
var cbHtml = ' <div class= " checkbox-multi-group " > ' ;
opts . forEach ( function ( o ) {
var isChecked = checked . indexOf ( o . value ) != = - 1 ;
cbHtml + = ' <label class= " checkbox-multi-item " > ' +
' <input type= " checkbox " name= " ' + field + ' " value= " ' + esc ( o . value ) + ' " ' +
2026-05-24 00:08:14 -04:00
( isChecked ? ' checked ' : ' ' ) + ' class= " inline-edit-checkbox-multi " /> ' + esc ( o . label ) + ' </label> ' ;
2026-05-18 14:38:23 -04:00
} ) ;
cbHtml + = ' </div> ' ;
td . innerHTML = cbHtml ;
2026-05-17 03:26:01 -04:00
} else if ( inputType == = ' select ' ) {
var opts = fDef . options | | [ ] ;
var selHtml = ' <select name= " ' + field + ' " class= " form-select inline-edit-select " > ' ;
opts . forEach ( function ( o ) {
selHtml + = ' <option value= " ' + esc ( o . value ) + ' " ' +
( String ( val ) == = String ( o . value ) ? ' selected ' : ' ' ) + ' > ' + esc ( o . label ) + ' </option> ' ;
} ) ;
selHtml + = ' </select> ' ;
td . innerHTML = selHtml ;
2026-05-18 14:38:23 -04:00
} else if ( inputType == = ' number ' ) {
var minAttr = fDef . min != = undefined ? ' min= " ' + esc ( String ( fDef . min ) ) + ' " ' : ' ' ;
var maxAttr = fDef . max != = undefined ? ' max= " ' + esc ( String ( fDef . max ) ) + ' " ' : ' ' ;
td . innerHTML = ' <input type= " number " name= " ' + field + ' " value= " ' + esc ( String ( val ) ) +
2026-05-25 02:51:38 -04:00
' " ' + minAttr + maxAttr + ' class= " form-input inline-edit-input " data-validate= " positive_int " /> ' +
2026-05-25 21:31:20 -04:00
' <p class= " form-hint field-dyn-hint hidden " ></p> ' ;
2026-05-25 02:51:38 -04:00
if ( typeof validateEl == = ' function ' ) validateEl ( td . querySelector ( ' input ' ) ) ;
2026-05-17 03:26:01 -04:00
} else if ( inputType == = ' textarea ' ) {
var textVal ;
try { var arr = JSON . parse ( val ) ; textVal = Array . isArray ( arr ) ? arr . join ( ' \n ' ) : String ( val | | ' ' ) ; }
catch ( ex ) { textVal = String ( val | | ' ' ) ; }
td . innerHTML = ' <textarea name= " ' + field + ' " rows= " 3 " class= " form-input inline-edit-textarea " > ' +
esc ( textVal ) + ' </textarea> ' ;
} else if ( inputType == = ' credentials ' ) {
td . innerHTML = buildCredentialsHtml ( rowData . provider | | ' noip ' , rowData ) ;
} else {
2026-05-20 04:06:50 -04:00
var validateAttr = fDef . validate ? ' data-validate= " ' + esc ( fDef . validate ) + ' " ' : ' ' ;
2026-05-25 21:31:20 -04:00
var hintHtml = fDef . validate ? ' <p class= " form-hint field-dyn-hint hidden " ></p> ' : ' ' ;
2026-05-17 03:26:01 -04:00
td . innerHTML = ' <input type= " ' + inputType + ' " name= " ' + field +
2026-05-24 00:08:14 -04:00
' " value= " ' + esc ( String ( val ) ) + ' " class= " form-input inline-edit-input " ' + validateAttr + ' /> ' + hintHtml ;
2026-05-20 04:06:50 -04:00
if ( fDef . validate & & typeof validateEl == = ' function ' ) validateEl ( td . querySelector ( ' input ' ) ) ;
2026-05-17 03:26:01 -04:00
}
} ) ;
var providerTd = tr . querySelector ( ' td[data-field= " provider " ] ' ) ;
var credsTd = tr . querySelector ( ' td[data-field= " credentials " ] ' ) ;
if ( providerTd & & credsTd ) {
var provSel = providerTd . querySelector ( ' select ' ) ;
if ( provSel ) {
provSel . addEventListener ( ' change ' , function ( ) {
credsTd . innerHTML = buildCredentialsHtml ( this . value , rowData ) ;
} ) ;
}
}
var actTd = tr . querySelector ( ' .col-actions ' ) ;
if ( actTd ) {
actTd . dataset . origActions = actTd . innerHTML ;
actTd . innerHTML =
' <button type= " button " class= " btn btn-primary btn-sm inline-save-btn " ' +
' data-action= " ' + action + ' " data-row-index= " ' + idx + ' " >Save</button> ' +
' <button type= " button " class= " btn btn-ghost btn-sm inline-cancel-btn " >Cancel</button> ' ;
actTd . querySelector ( ' .inline-save-btn ' ) . addEventListener ( ' click ' , function ( ) {
var f = document . createElement ( ' form ' ) ;
f . method = ' post ' ;
f . action = this . dataset . action ;
f . style . display = ' none ' ;
var addHidden = function ( name , value ) {
var inp = document . createElement ( ' input ' ) ;
inp . type = ' hidden ' ; inp . name = name ; inp . value = value ;
f . appendChild ( inp ) ;
} ;
addHidden ( ' row_index ' , this . dataset . rowIndex ) ;
addHidden ( ' config_hash ' , typeof CONFIG_HASH != = ' undefined ' ? CONFIG_HASH : ' ' ) ;
tr . querySelectorAll ( ' td[data-field] input[name], td[data-field] textarea[name], td[data-field] select[name] ' ) . forEach ( function ( inp ) {
if ( inp . type == = ' checkbox ' ) {
2026-05-18 14:38:23 -04:00
if ( inp . classList . contains ( ' inline-edit-checkbox-multi ' ) ) {
if ( inp . checked ) addHidden ( inp . name , inp . value ) ;
} else {
if ( inp . checked ) addHidden ( inp . name , ' on ' ) ;
}
2026-05-17 03:26:01 -04:00
} else {
addHidden ( inp . name , inp . value ) ;
}
} ) ;
document . body . appendChild ( f ) ;
f . submit ( ) ;
} ) ;
actTd . querySelector ( ' .inline-cancel-btn ' ) . addEventListener ( ' click ' , function ( ) {
tr . querySelectorAll ( ' td[data-field] ' ) . forEach ( function ( td ) {
if ( td . dataset . orig != = undefined ) td . innerHTML = td . dataset . orig ;
} ) ;
actTd . innerHTML = actTd . dataset . origActions ;
} ) ;
}
} ) ;
2026-05-18 14:38:23 -04:00
document . querySelectorAll ( ' select[data-filter-col] ' ) . forEach ( function ( sel ) {
function applyFilter ( ) {
var col = sel . dataset . filterCol ;
var val = sel . value ;
var toolbar = sel . closest ( ' .table-toolbar ' ) ;
if ( ! toolbar ) return ;
var wrapper = toolbar . nextElementSibling ;
if ( ! wrapper | | ! wrapper . classList . contains ( ' table-wrapper ' ) ) return ;
wrapper . querySelectorAll ( ' tbody tr ' ) . forEach ( function ( tr ) {
if ( val == = ' all ' ) {
tr . style . display = ' ' ;
} else {
var td = tr . querySelector ( ' td[data-field= " ' + col + ' " ] ' ) ;
tr . style . display = ( td & & td . textContent . trim ( ) == = val ) ? ' ' : ' none ' ;
}
} ) ;
}
sel . addEventListener ( ' change ' , applyFilter ) ;
} ) ;
2026-05-17 03:26:01 -04:00
document . querySelectorAll ( ' .js-hide-card ' ) . forEach ( function ( btn ) {
btn . addEventListener ( ' click ' , function ( e ) {
e . preventDefault ( ) ;
var card = this . closest ( ' .card ' ) ;
if ( card ) card . style . display = ' none ' ;
} ) ;
} ) ;
2026-05-20 04:06:50 -04:00
function _elMakeRow ( list , value ) {
var row = document . createElement ( ' div ' ) ;
row . className = ' editable-list-item ' ;
var inp = document . createElement ( ' input ' ) ;
inp . type = ' text ' ; inp . name = list . dataset . name ; inp . value = value ;
inp . placeholder = list . dataset . placeholder | | ' ' ; inp . className = ' form-input ' ;
var btn = document . createElement ( ' button ' ) ;
btn . type = ' button ' ; btn . className = ' btn btn-ghost btn-sm editable-list-remove ' ;
btn . textContent = ' Remove ' ;
btn . addEventListener ( ' click ' , function ( ) { row . remove ( ) ; } ) ;
row . appendChild ( inp ) ; row . appendChild ( btn ) ;
return row ;
}
document . querySelectorAll ( ' .editable-list ' ) . forEach ( function ( list ) {
list . querySelectorAll ( ' .editable-list-item ' ) . forEach ( function ( row ) {
row . querySelector ( ' .editable-list-remove ' ) . addEventListener ( ' click ' , function ( ) { row . remove ( ) ; } ) ;
} ) ;
list . querySelector ( ' .editable-list-add ' ) . addEventListener ( ' click ' , function ( ) {
list . insertBefore ( _elMakeRow ( list , ' ' ) , this ) ;
} ) ;
} ) ;
var validateEl ;
2026-05-17 03:26:01 -04:00
( function ( ) {
2026-05-20 04:06:50 -04:00
var _ipMsgs = { invalid_char : ' Invalid character ' , invalid_struct : ' Invalid format ' ,
invalid_range : ' Octet out of range ' , invalid : ' Invalid IP address ' } ;
var _msgs = {
ip : _ipMsgs ,
ipv4 : _ipMsgs ,
ipv6 : _ipMsgs ,
mac : { invalid_char : ' Invalid character ' , invalid_struct : ' Invalid format ' ,
too_many : ' Too many groups ' , invalid_group : ' Each group must be exactly 2 hex characters ' } ,
subnet : { invalid_char : ' Invalid character ' , invalid_struct : ' Invalid format ' ,
range : ' Octet out of range ' } ,
url : { invalid_char : ' Invalid character ' , invalid_struct : ' Invalid URL format ' } ,
port : { invalid_char : ' Digits only ' , out_of_range : ' Must be between 1 and 65535 ' } ,
2026-05-21 03:45:14 -04:00
ipv4cidr : { invalid_char : ' Invalid character ' , invalid_struct : ' Prefix must be 0-32 ' ,
2026-05-20 04:06:50 -04:00
invalid_range : ' Octet out of range ' } ,
endpoint : { invalid_char : ' Invalid character ' , invalid_struct : ' Invalid hostname or IP ' ,
invalid_range : ' Octet out of range ' , invalid : ' Invalid IP address ' } ,
dashname : { invalid_char : ' Lowercase letters, digits and hyphens only ' ,
invalid_struct : ' No leading, trailing or consecutive hyphens ' } ,
domainname : { invalid_char : ' Letters, digits, hyphens and dots only ' ,
invalid_struct : ' Invalid domain format ' } ,
networkname : { invalid_char : ' Letters, digits, hyphens and underscores only ' ,
2026-05-23 03:11:14 -04:00
invalid_struct : ' No leading, trailing or consecutive special characters ' } ,
2026-05-25 02:51:38 -04:00
time_24h : { invalid_char : ' Digits and colon only ' , invalid_struct : ' Must be HH:MM in 24-hour format (e.g. 02:30) ' } ,
positive_int : { invalid_char : ' Digits only ' ,
out_of_range : function ( el ) {
var mn = ( el & & el . min != = ' ' ) ? el . min : null ;
var mx = ( el & & el . max != = ' ' ) ? el . max : null ;
if ( mn != = null & & mx != = null ) return ' Must be between ' + mn + ' and ' + mx ;
if ( mn != = null ) return ' Must be ≥ ' + mn ;
if ( mx != = null ) return ' Must be ≤ ' + mx ;
return ' Out of range ' ;
} }
2026-05-20 04:06:50 -04:00
} ;
var _classifiers = { ip : classifyIp , ipv4 : classifyIpv4 , ipv6 : classifyIpv6 , mac : classifyMac ,
subnet : classifySubnet , url : classifyUrl ,
port : classifyPort , ipv4cidr : classifyIpv4Cidr ,
endpoint : classifyEndpoint ,
2026-05-23 03:11:14 -04:00
dashname : classifyDashname , domainname : classifyDomainname , networkname : classifyNetworkname ,
2026-05-25 02:51:38 -04:00
time_24h : classifyTime24h , positive_int : classifyPositiveInt } ;
2026-05-20 04:06:50 -04:00
validateEl = function ( el ) {
var list = el . closest ( ' .editable-list[data-validate] ' ) ;
var vtype = el . dataset . validate | | ( list ? list . dataset . validate : ' ' ) ;
var classify = _classifiers [ vtype ] ;
if ( ! classify ) return ;
2026-05-25 02:51:38 -04:00
var cls = classify ( el . value , el ) ;
2026-05-20 04:06:50 -04:00
if ( list ) {
el . classList . remove ( ' field-invalid ' , ' field-warning ' ) ;
if ( cls == = ' incomplete ' ) el . classList . add ( ' field-warning ' ) ;
else if ( cls != = ' empty ' & & cls != = ' complete ' ) el . classList . add ( ' field-invalid ' ) ;
} else {
var msgs = _msgs [ vtype ] | | { } ;
if ( cls == = ' complete ' | | cls == = ' empty ' ) {
setFieldHint ( el , el . _postValidate ? el . _postValidate ( cls ) : ' ' , ' ok ' ) ;
} else if ( cls == = ' incomplete ' ) {
setFieldHint ( el , el . _postValidate ? el . _postValidate ( cls ) : ' ' , ' warning ' ) ;
} else {
2026-05-25 02:51:38 -04:00
var msgVal = msgs [ cls ] ;
setFieldHint ( el , typeof msgVal == = ' function ' ? msgVal ( el ) : ( msgVal | | ' Invalid ' ) , ' error ' ) ;
2026-05-20 04:06:50 -04:00
}
}
} ;
2026-05-21 03:45:14 -04:00
/ / Regular fields ( not inside editable lists ) - initial state + expose _triggerValidate
2026-05-20 04:06:50 -04:00
document . querySelectorAll ( ' input[data-validate] ' ) . forEach ( function ( el ) {
if ( el . closest ( ' .editable-list ' ) ) return ;
el . _triggerValidate = function ( ) { validateEl ( el ) ; } ;
validateEl ( el ) ;
} ) ;
/ / Document - level delegation for regular fields ( covers static + dynamically added inputs )
document . addEventListener ( ' input ' , function ( ev ) {
var el = ev . target ;
if ( el . tagName != = ' INPUT ' | | ! el . dataset . validate | | el . closest ( ' .editable-list ' ) ) return ;
validateEl ( el ) ;
} ) ;
/ / Editable lists : validate existing items , delegation + MutationObserver for added items
document . querySelectorAll ( ' .editable-list[data-validate] ' ) . forEach ( function ( list ) {
if ( ! _classifiers [ list . dataset . validate ] ) return ;
list . querySelectorAll ( ' input ' ) . forEach ( function ( inp ) { validateEl ( inp ) ; } ) ;
list . addEventListener ( ' input ' , function ( ev ) {
if ( ev . target . tagName == = ' INPUT ' ) validateEl ( ev . target ) ;
2026-05-17 03:26:01 -04:00
} ) ;
2026-05-20 04:06:50 -04:00
new MutationObserver ( function ( mutations ) {
mutations . forEach ( function ( m ) {
m . addedNodes . forEach ( function ( node ) {
if ( node . nodeType != = 1 ) return ;
var inp = node . querySelector ? node . querySelector ( ' input ' ) : null ;
if ( inp ) validateEl ( inp ) ;
} ) ;
2026-05-17 03:26:01 -04:00
} ) ;
2026-05-20 04:06:50 -04:00
} ) . observe ( list , { childList : true } ) ;
} ) ;
} ) ( ) ;
( function ( ) {
document . querySelectorAll ( ' form ' ) . forEach ( function ( form ) {
var origInput = form . querySelector ( ' input[name= " original_values " ] ' ) ;
if ( ! origInput ) return ;
var original ;
try { original = JSON . parse ( origInput . value ) ; } catch ( ex ) { return ; }
var submitBtns = form . querySelectorAll ( ' button[type= " submit " ] ' ) ;
var cancelBtns = form . querySelectorAll ( ' .btn-cancel ' ) ;
submitBtns . forEach ( function ( b ) { b . disabled = true ; } ) ;
cancelBtns . forEach ( function ( b ) { b . disabled = true ; } ) ;
2026-05-21 03:45:14 -04:00
/ / Only track fields named in original - naturally excludes config_hash ,
2026-05-20 04:06:50 -04:00
/ / row_index , etc . , while including hidden inputs ( e . g . picker values ) .
function snapshot ( ) {
var state = { } ;
Object . keys ( original ) . forEach ( function ( k ) { if ( Array . isArray ( original [ k ] ) ) state [ k ] = [ ] ; } ) ;
form . querySelectorAll ( ' input, select, textarea ' ) . forEach ( function ( el ) {
if ( ! el . name | | ! ( el . name in original ) ) return ;
var val = el . type == = ' checkbox ' ? ( el . checked ? ' 1 ' : ' 0 ' ) : el . value ;
if ( Array . isArray ( state [ el . name ] ) ) { state [ el . name ] . push ( val ) ; }
else if ( Array . isArray ( original [ el . name ] ) ) { state [ el . name ] = [ val ] ; }
else { state [ el . name ] = val ; }
} ) ;
return JSON . stringify ( state ) ;
}
var baseSnap = snapshot ( ) ;
function checkDirty ( ) {
var dirty = snapshot ( ) != = baseSnap ;
submitBtns . forEach ( function ( b ) { b . disabled = ! dirty ; } ) ;
cancelBtns . forEach ( function ( b ) { b . disabled = ! dirty ; } ) ;
2026-05-17 03:26:01 -04:00
}
2026-05-20 04:06:50 -04:00
function resetToBase ( ) {
/ / Reset editable lists ( DOM rebuild )
form . querySelectorAll ( ' .editable-list ' ) . forEach ( function ( list ) {
var addBtn = list . querySelector ( ' .editable-list-add ' ) ;
list . querySelectorAll ( ' .editable-list-item ' ) . forEach ( function ( r ) { r . remove ( ) ; } ) ;
( original [ list . dataset . name ] | | [ ] ) . forEach ( function ( v ) {
list . insertBefore ( _elMakeRow ( list , v ) , addBtn ) ;
} ) ;
} ) ;
/ / Reset all tracked inputs ; dispatch change so custom widgets update themselves
form . querySelectorAll ( ' input, select, textarea ' ) . forEach ( function ( el ) {
if ( ! el . name | | ! ( el . name in original ) | | el . closest ( ' .editable-list ' ) ) return ;
var orig = original [ el . name ] ;
var newVal = orig != = undefined ? String ( orig ) : ' ' ;
2026-05-17 03:26:01 -04:00
if ( el . type == = ' checkbox ' ) {
2026-05-20 04:06:50 -04:00
el . checked = ( orig == = ' 1 ' ) ;
el . dispatchEvent ( new Event ( ' change ' , { bubbles : true } ) ) ;
} else if ( el . value != = newVal ) {
el . value = newVal ;
el . dispatchEvent ( new Event ( ' change ' , { bubbles : true } ) ) ;
2026-05-17 03:26:01 -04:00
}
} ) ;
2026-05-20 04:06:50 -04:00
checkDirty ( ) ;
form . querySelectorAll ( ' input[data-validate] ' ) . forEach ( function ( el ) {
if ( typeof validateEl == = ' function ' ) validateEl ( el ) ;
} ) ;
}
2026-05-17 03:26:01 -04:00
2026-05-20 04:06:50 -04:00
cancelBtns . forEach ( function ( b ) { b . addEventListener ( ' click ' , resetToBase ) ; } ) ;
form . addEventListener ( ' input ' , checkDirty ) ;
form . addEventListener ( ' change ' , checkDirty ) ;
new MutationObserver ( checkDirty ) . observe ( form , { childList : true , subtree : true } ) ;
form . _resetDirtyState = function ( ) {
baseSnap = snapshot ( ) ;
submitBtns . forEach ( function ( b ) { b . disabled = true ; } ) ;
cancelBtns . forEach ( function ( b ) { b . disabled = true ; } ) ;
} ;
2026-05-17 03:26:01 -04:00
} ) ;
2026-05-20 04:06:50 -04:00
} ) ( ) ;
2026-05-17 03:26:01 -04:00
( function ( ) {
function updateCredFields ( container , provider ) {
var tokenGrp = container . querySelector ( ' .cred-group-token ' ) ;
var noipGrp = container . querySelector ( ' .cred-group-noip ' ) ;
if ( ! tokenGrp | | ! noipGrp ) return ;
tokenGrp . style . display = ( provider == = ' noip ' ) ? ' none ' : ' ' ;
noipGrp . style . display = ( provider == = ' noip ' ) ? ' ' : ' none ' ;
}
document . querySelectorAll ( ' .credential-fields ' ) . forEach ( function ( container ) {
var selName = container . dataset . providerSelect ;
var form = container . closest ( ' form ' ) ;
if ( ! form | | ! selName ) return ;
var sel = form . querySelector ( ' [name= " ' + selName + ' " ] ' ) ;
if ( ! sel ) return ;
updateCredFields ( container , sel . value ) ;
sel . addEventListener ( ' change ' , function ( ) { updateCredFields ( container , this . value ) ; } ) ;
} ) ;
} ) ( ) ;
2026-05-20 04:06:50 -04:00
2026-05-25 23:57:34 -04:00
function timingPhrase ( n ) {
return n < = 5 ? ' momentarily '
: n < 60 ? ' in about ' + n + ' seconds '
: ' in about ' + Math . round ( n / 60 ) + ' minute ' + ( Math . round ( n / 60 ) != = 1 ? ' s ' : ' ' ) ;
}
function startPoller ( uuid , handlers ) {
2026-05-20 04:06:50 -04:00
var nextIn = null ;
var pollTimer = null ;
var tickTimer = null ;
function onStatus ( data ) {
if ( data . status == = ' complete ' ) {
2026-05-25 23:57:34 -04:00
clearTimeout ( pollTimer ) ; clearTimeout ( tickTimer ) ;
handlers . onComplete ( ) ; return ;
2026-05-20 04:06:50 -04:00
}
if ( data . status == = ' running ' ) {
2026-05-25 23:57:34 -04:00
handlers . onRunning ( ) ;
2026-05-20 04:06:50 -04:00
} else {
if ( data . next_in != = null & & data . next_in != = undefined ) { nextIn = data . next_in ; }
2026-05-25 23:57:34 -04:00
handlers . onPending ( nextIn ) ;
2026-05-20 04:06:50 -04:00
}
pollTimer = setTimeout ( doPoll , 3000 ) ;
}
function doPoll ( ) {
2026-05-25 01:04:47 -04:00
fetch ( ' /api/apply-health?uuid= ' + encodeURIComponent ( uuid ) )
2026-05-20 04:06:50 -04:00
. then ( function ( r ) { return r . json ( ) ; } )
. then ( onStatus )
. catch ( function ( ) { pollTimer = setTimeout ( doPoll , 3000 ) ; } ) ;
}
function tick ( ) {
2026-05-25 23:57:34 -04:00
if ( nextIn != = null & & nextIn > 0 ) { nextIn - - ; handlers . onPending ( nextIn ) ; }
2026-05-20 04:06:50 -04:00
tickTimer = setTimeout ( tick , 1000 ) ;
}
doPoll ( ) ;
tick ( ) ;
}
2026-05-25 23:57:34 -04:00
function startApplyPoller ( uuid , bar , mine ) {
function user ( ) { return bar . getAttribute ( ' data-apply-user ' ) | | ' ' ; }
function esc ( s ) { return s . replace ( / & / g , ' & ' ) . replace ( / < / g , ' < ' ) . replace ( / > / g , ' > ' ) ; }
startPoller ( uuid , {
onPending : function ( nextIn ) {
bar . classList . remove ( ' info-bar-running ' ) ;
bar . innerHTML = nextIn == = null
? ( mine ? ' Configuration saved. The processing service is not running. '
: esc ( user ( ) ) + ' has pending changes. The processing service is not running. ' )
: ( mine ? ' Configuration saved. Your changes will be applied ' + timingPhrase ( nextIn ) + ' . '
: esc ( user ( ) ) + ' has pending changes which will be applied ' + timingPhrase ( nextIn ) + ' . ' ) ;
} ,
onRunning : function ( ) {
bar . classList . add ( ' info-bar-running ' ) ;
bar . innerHTML = mine ? ' Configuration saved. Your changes are being applied now... '
: esc ( user ( ) ) + ' \' s changes are being applied now... ' ;
} ,
onComplete : function ( ) {
bar . classList . remove ( ' info-bar-running ' ) ;
bar . innerHTML = mine ? ' Your changes have been applied. '
: esc ( user ( ) ) + ' \' s changes have been applied. ' ;
}
} ) ;
}
2026-05-20 04:06:50 -04:00
( function ( ) {
if ( typeof APPLY_UUID != = ' undefined ' & & APPLY_UUID ) {
var bar = document . querySelector ( ' .info-bar-flash.info-bar-success ' ) ;
if ( bar ) startApplyPoller ( APPLY_UUID , bar , true ) ;
}
document . querySelectorAll ( ' [data-apply-uuid] ' ) . forEach ( function ( bar ) {
startApplyPoller ( bar . getAttribute ( ' data-apply-uuid ' ) , bar , false ) ;
} ) ;
2026-05-25 23:57:34 -04:00
document . querySelectorAll ( ' [data-health-uuid] ' ) . forEach ( function ( el ) {
startPoller ( el . getAttribute ( ' data-health-uuid ' ) , {
onPending : function ( nextIn ) {
el . textContent = nextIn == = null ? ' Fix pending. The processing service is not running. '
: ' Fix will be applied ' + timingPhrase ( nextIn ) + ' . ' ;
} ,
onRunning : function ( ) { el . textContent = ' Fix is being applied now... ' ; } ,
onComplete : function ( ) { el . textContent = ' Fix has been applied. ' ; }
} ) ;
} ) ;
2026-05-20 04:06:50 -04:00
} ) ( ) ;
( function ( ) {
function closeAll ( ) {
document . querySelectorAll ( ' .iface-picker-dropdown.open ' ) . forEach ( function ( d ) {
d . classList . remove ( ' open ' ) ;
} ) ;
}
document . querySelectorAll ( ' .iface-picker ' ) . forEach ( function ( picker ) {
var btn = picker . querySelector ( ' .iface-picker-btn ' ) ;
var header = picker . querySelector ( ' .iface-picker-header ' ) ;
var dropdown = picker . querySelector ( ' .iface-picker-dropdown ' ) ;
var hidden = picker . querySelector ( ' input[type= " hidden " ] ' ) ;
function applySelection ( iface ) {
var row = dropdown . querySelector ( ' .iface-picker-row[data-iface= " ' + iface + ' " ] ' ) ;
if ( ! row ) return ;
btn . querySelector ( ' .iface-picker-name ' ) . textContent = iface ;
var badge = btn . querySelector ( ' .iface-picker-badge ' ) ;
if ( ! badge ) { badge = document . createElement ( ' span ' ) ; btn . appendChild ( badge ) ; }
badge . className = ' badge ' + row . dataset . stateClass + ' iface-picker-badge ' ;
badge . textContent = row . dataset . stateLabel ;
var stats = header . querySelector ( ' .iface-picker-stats ' ) ;
if ( ! stats ) {
stats = document . createElement ( ' table ' ) ;
stats . className = ' iface-picker-stats ' ;
stats . innerHTML = ' <thead><tr><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead><tbody><tr></tr></tbody> ' ;
header . appendChild ( stats ) ;
}
stats . querySelector ( ' tbody tr ' ) . innerHTML =
' <td> ' + ( row . dataset . speed | | ' - ' ) + ' </td> '
+ ' <td> ' + ( row . dataset . mtu | | ' - ' ) + ' </td> '
+ ' <td class= " col-mono " > ' + ( row . dataset . mac | | ' - ' ) + ' </td> ' ;
dropdown . querySelectorAll ( ' .iface-picker-row ' ) . forEach ( function ( r ) {
r . classList . toggle ( ' selected ' , r == = row ) ;
} ) ;
}
hidden . addEventListener ( ' change ' , function ( ) { applySelection ( hidden . value ) ; } ) ;
btn . addEventListener ( ' click ' , function ( e ) {
e . stopPropagation ( ) ;
var wasOpen = dropdown . classList . contains ( ' open ' ) ;
closeAll ( ) ;
if ( ! wasOpen ) dropdown . classList . add ( ' open ' ) ;
} ) ;
dropdown . addEventListener ( ' click ' , function ( e ) { e . stopPropagation ( ) ; } ) ;
dropdown . querySelectorAll ( ' .iface-picker-row ' ) . forEach ( function ( row ) {
row . addEventListener ( ' click ' , function ( ) {
hidden . value = this . dataset . iface ;
closeAll ( ) ;
hidden . dispatchEvent ( new Event ( ' change ' , { bubbles : true } ) ) ;
} ) ;
} ) ;
} ) ;
document . addEventListener ( ' click ' , closeAll ) ;
document . addEventListener ( ' keydown ' , function ( e ) {
if ( e . key == = ' Escape ' ) closeAll ( ) ;
} ) ;
document . querySelectorAll ( ' .iface-configure-btn ' ) . forEach ( function ( btn ) {
btn . addEventListener ( ' click ' , function ( ) {
var card = document . getElementById ( ' iface-config-card ' ) ;
if ( ! card ) return ;
var form = card . querySelector ( ' form ' ) ;
if ( ! form ) return ;
form . querySelector ( ' [name= " iface " ] ' ) . value = this . dataset . iface ;
var minMtu = this . dataset . minMtu != = ' ' ? parseInt ( this . dataset . minMtu ) : null ;
var maxMtu = this . dataset . maxMtu != = ' ' ? parseInt ( this . dataset . maxMtu ) : null ;
var mtuSel = form . querySelector ( ' [name= " mtu " ] ' ) ;
var originalMtu = this . dataset . mtu | | ' ' ;
if ( mtuSel ) {
Array . from ( mtuSel . options ) . forEach ( function ( opt ) {
var v = parseInt ( opt . value ) ;
var out = ! isNaN ( v ) & & ( ( minMtu != = null & & v < minMtu ) | | ( maxMtu != = null & & v > maxMtu ) ) ;
opt . disabled = out ;
opt . hidden = out ;
} ) ;
mtuSel . value = originalMtu ;
if ( ! mtuSel . value | | mtuSel . selectedOptions [ 0 ] . disabled ) {
var first = Array . from ( mtuSel . options ) . find ( function ( o ) { return ! o . disabled ; } ) ;
if ( first ) mtuSel . value = first . value ;
originalMtu = mtuSel . value ;
}
}
var origMtuField = form . querySelector ( ' [name= " original_mtu " ] ' ) ;
if ( origMtuField ) origMtuField . value = originalMtu ;
var macInput = form . querySelector ( ' [name= " mac " ] ' ) ;
var originalMac = this . dataset . mac | | ' ' ;
if ( macInput ) {
macInput . dataset . permMac = this . dataset . permMac | | ' ' ;
macInput . value = originalMac ;
if ( macInput . _triggerValidate ) macInput . _triggerValidate ( ) ;
}
var origMacField = form . querySelector ( ' [name= " original_mac " ] ' ) ;
if ( origMacField ) origMacField . value = originalMac ;
if ( form . _resetDirtyState ) form . _resetDirtyState ( ) ;
2026-05-23 00:37:35 -04:00
showCard ( card ) ;
2026-05-20 04:06:50 -04:00
card . scrollIntoView ( { behavior : ' smooth ' , block : ' nearest ' } ) ;
} ) ;
} ) ;
document . querySelectorAll ( ' .iface-config-cancel ' ) . forEach ( function ( a ) {
a . addEventListener ( ' click ' , function ( ev ) {
ev . preventDefault ( ) ;
var card = document . getElementById ( ' iface-config-card ' ) ;
if ( card ) card . style . display = ' none ' ;
} ) ;
} ) ;
} ) ( ) ;
( function ( ) {
var card = document . getElementById ( ' iface-config-card ' ) ;
if ( ! card ) return ;
var macInput = card . querySelector ( ' input[name= " mac " ] ' ) ;
if ( ! macInput | | ! macInput . _triggerValidate ) return ;
macInput . _postValidate = function ( ) {
return macInput . dataset . permMac ? ' Factory default: ' + macInput . dataset . permMac : ' ' ;
} ;
macInput . _triggerValidate ( ) ;
} ) ( ) ;
2026-05-23 17:02:41 -04:00
document . querySelectorAll ( ' .pre-block[data-scroll-bottom] ' ) . forEach ( function ( el ) {
el . scrollTop = el . scrollHeight ;
} ) ;
2026-05-24 03:02:10 -04:00
document . querySelectorAll ( ' [data-reveal-card] ' ) . forEach ( function ( btn ) {
btn . addEventListener ( ' click ' , function ( ) {
var card = document . getElementById ( btn . dataset . revealCard ) ;
2026-05-24 03:08:20 -04:00
if ( ! card ) return ;
if ( card . style . display == = ' none ' ) { showCard ( card ) ; } else { card . style . display = ' none ' ; }
2026-05-24 03:02:10 -04:00
} ) ;
} ) ;
2026-05-24 02:28:52 -04:00
( function ( ) {
2026-05-24 02:42:11 -04:00
document . querySelectorAll ( ' .stat-card-editable ' ) . forEach ( function ( card ) {
var form = card . querySelector ( ' .stat-card-edit-form ' ) ;
var input = form ? form . querySelector ( ' input[data-original] ' ) : null ;
var saveBtn = form ? form . querySelector ( ' button[type= " submit " ] ' ) : null ;
function updateSave ( ) {
if ( input & & saveBtn ) saveBtn . disabled = ( input . value == = input . dataset . original ) ;
}
if ( input ) input . addEventListener ( ' input ' , updateSave ) ;
card . querySelector ( ' .stat-card-edit-btn ' ) . addEventListener ( ' click ' , function ( ) {
2026-05-24 02:28:52 -04:00
card . querySelector ( ' .stat-card-view ' ) . style . display = ' none ' ;
2026-05-24 02:42:11 -04:00
form . style . display = ' ' ;
2026-05-24 02:28:52 -04:00
} ) ;
2026-05-24 02:42:11 -04:00
form & & form . querySelector ( ' .stat-card-cancel-btn ' ) . addEventListener ( ' click ' , function ( ) {
2026-05-24 02:28:52 -04:00
card . querySelector ( ' .stat-card-view ' ) . style . display = ' ' ;
2026-05-24 02:42:11 -04:00
form . style . display = ' none ' ;
if ( input ) { input . value = input . dataset . original ; updateSave ( ) ; }
2026-05-24 02:28:52 -04:00
} ) ;
} ) ;
} ) ( ) ;
2026-05-17 03:26:01 -04:00
"""
2026-05-21 03:45:14 -04:00
# Routes ============================================================
2026-05-17 03:26:01 -04:00
@bp.route ( ' / ' )
def index ( ) :
return _serve_view ( ' view_overview ' )
@bp.route ( ' /view/<view_id> ' )
def view ( view_id ) :
return _serve_view ( view_id )
def _serve_view ( view_id ) :
content_data = _load_json ( f ' { DATA_DIR } /page_content.json ' )
view_def = next ( ( v for v in content_data . get ( ' views ' , [ ] ) if v . get ( ' id ' ) == view_id ) , None )
if view_def is None :
from flask import abort
abort ( 404 )
view_req = view_def . get ( ' client_requirement ' )
level = _client_level ( )
if not _passes ( view_req , level ) :
return redirect ( ' /view/view_overview ' if level > 0 else ' /view/view_log_in ' )
tokens = collect_tokens ( )
flash_html = ' '
for category , message in get_flashed_messages ( with_categories = True ) :
variant = { ' error ' : ' danger ' , ' warning ' : ' warning ' , ' success ' : ' success ' } . get ( category , ' info ' )
msg_html = message if isinstance ( message , Markup ) else e ( message )
2026-05-26 00:12:42 -04:00
flash_html + = f ' <div class= " info-bar info-bar- { variant } info-bar-flash " ><span> { msg_html } </span></div> '
2026-05-17 03:26:01 -04:00
content_html = flash_html + render_items ( view_def . get ( ' items ' , [ ] ) , tokens , view_req )
return render_layout ( view_id , content_html , tokens )