2026-05-17 03:26:01 -04:00
from flask import Blueprint , session , redirect , get_flashed_messages
from markupsafe import Markup
2026-06-01 10:07:57 -04:00
import json , re , subprocess , os , sys , glob
2026-05-17 03:26:01 -04:00
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-30 14:57:33 -04:00
from config_utils import config_hash , get_pending_entries , get_dashboard_pending , get_dashboard_done , load_all_groups , revert_group , get_done_timestamps , queue_command , _find_cmd_in_queues , _entry_ts_from_queue , _apply_changes_immediately , _seconds_until_next_run , _format_timing , _is_locked , _lock_mtime , WEB_APP_DISPLAY_NAME , CONFIGS_DIR , DATA_DIR , WWW_DIR , ACCOUNTS_FILE , APP_DIR
2026-05-28 22:50:00 -04:00
import factory
from factory import LEVEL_RANK , e , client_level , passes , build_items , build_snap_val , snap_expand_row
PAGES_DIR = os . path . join ( APP_DIR , ' pages ' )
NAVBAR_FILE = os . path . join ( APP_DIR , ' navbar.json ' )
CSS_FILE = os . path . join ( DATA_DIR , ' styles.css ' )
COMMON_JS_FILE = os . path . join ( DATA_DIR , ' common.js ' )
BLOCKLISTS_DIR = os . path . join ( CONFIGS_DIR , ' blocklists ' )
HEALTH_FILE = os . path . join ( CONFIGS_DIR , ' .health ' )
2026-05-17 03:26:01 -04:00
bp = Blueprint ( ' view_page ' , __name__ )
2026-06-01 22:54:49 -04:00
try :
import manuf as _manuf_mod
_mac_parser = _manuf_mod . MacParser ( )
except Exception :
_mac_parser = None
def _get_vendor ( mac ) :
if _mac_parser is None :
return ' '
try :
2026-06-01 23:09:45 -04:00
return _mac_parser . get_comment ( mac ) or _mac_parser . get_manuf ( mac ) or ' '
2026-06-01 22:54:49 -04:00
except Exception :
return ' '
2026-05-17 03:26:01 -04:00
2026-05-21 03:45:14 -04:00
# File loaders ======================================================
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def load_json ( path ) :
2026-05-17 03:26:01 -04:00
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-28 22:50:00 -04:00
def load_config ( ) : return load_json ( f ' { CONFIGS_DIR } /config.json ' )
def load_ddns ( ) : return load_config ( ) . get ( ' ddns ' , { } )
def load_accounts ( ) : return load_json ( ACCOUNTS_FILE )
2026-05-17 03:26:01 -04:00
def _load_css ( ) :
try :
2026-05-28 22:50:00 -04:00
with open ( CSS_FILE ) as f :
2026-05-17 03:26:01 -04:00
return f . read ( )
except Exception :
return ' '
2026-05-26 20:14:56 -04:00
def _load_icon ( name ) :
try :
with open ( f ' { WWW_DIR } /icons/ { name } .svg ' ) as f :
return f . read ( ) . strip ( )
except Exception :
return ' '
2026-05-27 20:56:30 -04:00
2026-05-21 03:45:14 -04:00
# Shell helper ======================================================
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def run ( cmd ) :
2026-05-17 03:26:01 -04:00
try :
r = subprocess . run ( cmd , shell = True , capture_output = True , text = True , timeout = 5 )
return r . stdout . strip ( )
except Exception :
return ' '
2026-05-28 22:50:00 -04:00
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 [ ]
2026-05-28 22:50:00 -04:00
def iface_info ( iface ) :
2026-05-20 04:06:50 -04:00
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
2026-05-26 15:17:36 -04:00
wireless = os . path . isdir ( f ' { base } /wireless ' )
state = ( _rd ( ' operstate ' ) or ' unknown ' ) . upper ( )
2026-05-20 04:06:50 -04:00
if state == ' UNKNOWN ' :
2026-05-26 15:17:36 -04:00
state = ' UP '
2026-05-20 04:06:50 -04:00
carrier_raw = _rd ( ' carrier ' )
2026-05-26 15:17:36 -04:00
carrier = ( carrier_raw == ' 1 ' ) if carrier_raw is not None else None
speed_raw = _rd ( ' speed ' )
2026-05-20 04:06:50 -04:00
try :
2026-05-26 15:17:36 -04:00
mbps = int ( speed_raw )
2026-05-20 04:06:50 -04:00
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
2026-05-26 15:17:36 -04:00
mac = _rd ( ' address ' )
2026-05-20 04:06:50 -04:00
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
2026-05-28 22:50:00 -04:00
def iface_status ( iface ) :
2026-05-18 14:38:23 -04:00
""" 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-28 22:50:00 -04:00
def resolve_iface ( vlan , cfg ) :
2026-05-27 15:29:23 -04:00
""" Compute interface name from is_vpn + stored 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-27 15:29:23 -04:00
wg_sorted = sorted ( wg_vlans , key = lambda v : ( v . get ( ' vlan_id ' ) is None , v . get ( ' vlan_id ' ) or 0 ) )
2026-05-20 17:49:00 -04:00
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-27 15:29:23 -04:00
vid = vlan . get ( ' vlan_id ' ) 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
2026-06-01 22:12:11 -04:00
def _parse_lease_secs ( s ) :
s = str ( s ) . strip ( ) . lower ( )
try :
if s . endswith ( ' h ' ) : return int ( s [ : - 1 ] ) * 3600
if s . endswith ( ' m ' ) : return int ( s [ : - 1 ] ) * 60
if s . endswith ( ' d ' ) : return int ( s [ : - 1 ] ) * 86400
except ValueError :
pass
return None
def _dnsmasq_start_time ( vlan_name ) :
""" Return epoch timestamp when the dnsmasq instance for this VLAN last started. """
try :
pid = int ( open ( f ' /run/dnsmasq-routlin- { vlan_name } .pid ' ) . read ( ) . strip ( ) )
start_ticks = int ( open ( f ' /proc/ { pid } /stat ' ) . read ( ) . split ( ) [ 21 ] )
clk_tck = os . sysconf ( ' SC_CLK_TCK ' )
boot_time = next (
int ( line . split ( ) [ 1 ] ) for line in open ( ' /proc/stat ' ) if line . startswith ( ' btime ' )
)
return boot_time + start_ticks / clk_tck
except Exception :
return None
2026-05-28 22:50:00 -04:00
def live_dhcp_leases ( ) :
2026-05-17 03:26:01 -04:00
rows = [ ]
2026-06-01 22:12:11 -04:00
now = int ( datetime . now ( tz = timezone . utc ) . timestamp ( ) )
2026-06-01 22:44:32 -04:00
cfg = load_config ( )
vlans = cfg . get ( ' vlans ' , [ ] )
2026-06-01 22:12:11 -04:00
vlan_lease_secs = {
2026-06-01 22:44:32 -04:00
v [ ' name ' ] : _parse_lease_secs ( v . get ( ' dhcp_information ' , { } ) . get ( ' lease_time ' , ' ' ) )
2026-06-01 22:12:11 -04:00
for v in vlans if v . get ( ' name ' )
}
2026-06-01 22:44:32 -04:00
mac_to_res = {
r [ ' mac ' ] . lower ( ) : r [ ' hostname ' ]
for r in cfg . get ( ' dhcp_reservations ' , [ ] )
if r . get ( ' mac ' ) and r . get ( ' hostname ' )
}
2026-06-01 22:12:11 -04:00
for leases_file in glob . glob ( ' /var/lib/misc/dnsmasq-routlin-*.leases ' ) :
stem = os . path . basename ( leases_file )
vlan_name = stem [ len ( ' dnsmasq-routlin- ' ) : - len ( ' .leases ' ) ]
lease_secs = vlan_lease_secs . get ( vlan_name )
restart_time = _dnsmasq_start_time ( vlan_name )
2026-06-01 10:07:57 -04:00
try :
with open ( leases_file ) as f :
for line in f :
parts = line . strip ( ) . split ( )
2026-06-01 22:12:11 -04:00
if len ( parts ) < 4 :
continue
expiry = int ( parts [ 0 ] )
if expiry < now :
continue
obtained_ts = ( expiry - lease_secs ) if lease_secs else None
2026-06-01 23:09:45 -04:00
renews_ts = ( expiry - lease_secs / / 2 ) if lease_secs else None
2026-06-01 22:12:11 -04:00
recent = ( obtained_ts is not None and restart_time is not None
and obtained_ts > = restart_time )
2026-06-01 22:44:32 -04:00
mac_norm = parts [ 1 ] . lower ( )
device_h = parts [ 3 ] if parts [ 3 ] != ' * ' else None
res_h = mac_to_res . get ( mac_norm )
if res_h and device_h and device_h . lower ( ) != res_h . lower ( ) :
hostname_html = f ' <strong> { e ( res_h ) } </strong><br/>( { e ( device_h ) } ) '
elif res_h :
hostname_html = f ' <strong> { e ( res_h ) } </strong> '
elif device_h :
hostname_html = e ( device_h )
else :
hostname_html = ' - '
2026-06-01 22:12:11 -04:00
rows . append ( {
2026-06-01 22:44:32 -04:00
' hostname ' : hostname_html ,
2026-06-01 22:12:11 -04:00
' ip_address ' : parts [ 2 ] ,
' mac_address ' : parts [ 1 ] ,
2026-06-01 22:54:49 -04:00
' vendor ' : _get_vendor ( parts [ 1 ] ) ,
2026-06-01 22:12:11 -04:00
' vlan_name ' : vlan_name ,
2026-06-01 23:09:45 -04:00
' renews ' : relative_time_future ( renews_ts ) if renews_ts else relative_time_future ( expiry ) ,
2026-06-01 22:12:11 -04:00
' recent ' : recent ,
} )
2026-06-01 10:07:57 -04:00
except Exception :
pass
2026-05-17 03:26:01 -04:00
return rows
def _vlan_name_for_ip ( ip ) :
import ipaddress
2026-05-28 22:50:00 -04:00
for vlan in load_config ( ) . get ( ' vlans ' , [ ] ) :
2026-05-18 14:38:23 -04:00
subnet = vlan . get ( ' subnet ' , ' ' )
2026-05-26 15:17:36 -04:00
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 ' - '
2026-05-28 22:50:00 -04:00
def fmt_timestamp ( ts ) :
2026-05-17 03:26:01 -04:00
try :
return datetime . fromtimestamp ( ts , tz = timezone . utc ) . strftime ( ' % Y- % m- %d % H: % M UTC ' )
except Exception :
return ' - '
2026-05-28 22:50:00 -04:00
def relative_time ( ts ) :
2026-05-23 02:01:37 -04:00
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-06-01 22:12:11 -04:00
def relative_time_future ( ts ) :
try :
diff = int ( ts ) - int ( datetime . now ( tz = timezone . utc ) . timestamp ( ) )
if diff < = 0 :
return ' expired '
if diff < 60 :
return f ' in { diff } second { " s " if diff != 1 else " " } '
m = diff / / 60
if m < 60 :
return f ' in { m } minute { " s " if m != 1 else " " } '
h , rem_m = divmod ( m , 60 )
if h < 24 :
return f ' in { h } h { rem_m } m ' if rem_m else f ' in { h } hour { " s " if h != 1 else " " } '
d = h / / 24
return f ' in { d } day { " s " if d != 1 else " " } '
except Exception :
return ' '
2026-05-28 22:50:00 -04:00
def live_vpn_sessions ( ) :
2026-05-17 03:26:01 -04:00
rows = [ ]
2026-05-28 22:50:00 -04:00
out = run ( ' wg show all dump 2>/dev/null ' )
2026-05-17 03:26:01 -04:00
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 ' - ' ,
2026-05-28 22:50:00 -04:00
' 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 ' - ' ,
2026-05-17 03:26:01 -04:00
} )
return rows
2026-05-28 22:50:00 -04:00
def fmt_bytes ( n ) :
2026-05-17 03:26:01 -04:00
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
2026-05-28 22:50:00 -04:00
def config_datasource ( name ) :
cfg = load_config ( )
2026-05-25 19:59:42 -04:00
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 [
2026-05-28 22:50:00 -04:00
{ ' iface_type ' : ' WAN ' , ' interface ' : wan , ' status ' : iface_status ( wan ) } ,
{ ' iface_type ' : ' LAN ' , ' interface ' : lan , ' status ' : iface_status ( lan ) } ,
2026-05-18 14:38:23 -04:00
]
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 )
2026-05-28 22:50:00 -04:00
bl_path = os . path . join ( BLOCKLISTS_DIR , bl . get ( " save_as " , " " ) )
2026-05-17 03:26:01 -04:00
try :
with open ( bl_path ) as f :
row [ ' domain_count ' ] = str ( sum ( 1 for _ in f ) )
2026-05-28 22:50:00 -04:00
row [ ' last_updated ' ] = fmt_timestamp ( int ( os . path . getmtime ( bl_path ) ) )
2026-05-17 03:26:01 -04:00
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-27 15:29:23 -04:00
for v in sorted ( vlans , key = lambda x : x . get ( ' vlan_id ' ) 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-27 15:29:23 -04:00
row [ ' vlan_id ' ] = v . get ( ' vlan_id ' )
2026-05-28 22:50:00 -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-27 01:35:52 -04:00
_prefix = v . get ( ' subnet_mask ' , 24 )
_n_octets = 1 if _prefix > = 24 else 2 if _prefix > = 16 else 3 if _prefix > = 8 else 4
2026-05-27 00:42:54 -04:00
row [ ' server_identity_ips ' ] = json . dumps ( [
2026-05-27 01:35:52 -04:00
{
2026-05-27 02:33:50 -04:00
' n ' : s [ ' ip ' ] ,
' d ' : ' | ' . join ( filter ( None , [ s [ ' ip ' ] , s . get ( ' description ' ) , s . get ( ' hostname ' ) ] ) ) ,
2026-05-27 01:35:52 -04:00
' short ' : ' . ' + ' . ' . join ( s [ ' ip ' ] . split ( ' . ' ) [ - _n_octets : ] ) ,
' mini ' : ' . ' + ' . ' . join ( s [ ' ip ' ] . split ( ' . ' ) [ - _n_octets : ] ) ,
}
2026-05-27 00:47:28 -04:00
for s in v . get ( ' server_identities ' , [ ] ) if s . get ( ' ip ' )
2026-05-27 00:42:54 -04:00
] )
2026-05-27 00:58:05 -04:00
row [ ' server_identity_descriptions ' ] = json . dumps ( [
s . get ( ' description ' , ' ' ) for s in v . get ( ' server_identities ' , [ ] ) if s . get ( ' ip ' )
] )
2026-05-27 01:08:49 -04:00
row [ ' server_identity_hostnames ' ] = json . dumps ( [
s . get ( ' hostname ' , ' ' ) for s in v . get ( ' server_identities ' , [ ] ) if s . get ( ' ip ' )
] )
2026-05-27 03:28:44 -04:00
row [ ' server_identity_gateway ' ] = (
v . get ( ' dhcp_information ' , { } ) . get ( ' explicit_overrides ' , { } ) . get ( ' gateway ' , ' ' )
)
2026-05-31 02:17:25 -04:00
_dns = v . get ( ' dhcp_information ' , { } ) . get ( ' explicit_overrides ' , { } ) . get ( ' dns_servers ' , [ ] )
row [ ' server_identity_dns_servers ' ] = ' \n ' . join ( _dns ) if isinstance ( _dns , list ) else str ( _dns or ' ' )
_ntp = v . get ( ' dhcp_information ' , { } ) . get ( ' explicit_overrides ' , { } ) . get ( ' ntp_servers ' , [ ] )
row [ ' server_identity_ntp_servers ' ] = ' \n ' . join ( _ntp ) if isinstance ( _ntp , list ) else str ( _ntp or ' ' )
2026-05-31 18:24:04 -04:00
row [ ' gateway ' ] = row [ ' server_identity_gateway ' ]
row [ ' dns_servers ' ] = row [ ' server_identity_dns_servers ' ]
row [ ' ntp_servers ' ] = row [ ' server_identity_ntp_servers ' ]
row [ ' dns_servers_override ' ] = 1 if row [ ' server_identity_dns_servers ' ] else 0
row [ ' ntp_servers_override ' ] = 1 if row [ ' server_identity_ntp_servers ' ] else 0
_dhi = v . get ( ' dhcp_information ' , { } )
row [ ' dhcp_pool_start ' ] = _dhi . get ( ' dynamic_pool_start ' , ' ' )
row [ ' dhcp_pool_end ' ] = _dhi . get ( ' dynamic_pool_end ' , ' ' )
_lt = _dhi . get ( ' lease_time ' , ' ' )
if _lt and len ( _lt ) > 1 and _lt [ : - 1 ] . isdigit ( ) and _lt [ - 1 ] in ' mhd ' :
row [ ' dhcp_lease_time ' ] = _lt [ : - 1 ]
row [ ' dhcp_lease_unit ' ] = { ' m ' : ' minutes ' , ' h ' : ' hours ' , ' d ' : ' days ' } [ _lt [ - 1 ] ]
else :
row [ ' dhcp_lease_time ' ] = ' '
row [ ' dhcp_lease_unit ' ] = ' '
row [ ' dhcp_domain ' ] = _dhi . get ( ' domain ' , ' ' )
row [ ' server_identities_json ' ] = json . dumps ( v . get ( ' server_identities ' , [ ] ) )
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
2026-06-01 01:44:58 -04:00
if name == ' port_wrangling ' :
rows = [ ]
for r in cfg . get ( ' port_wrangling ' , [ ] ) :
row = dict ( r )
row [ ' vlan_name ' ] = r . get ( ' vlan ' , ' - ' )
rows . append ( row )
return rows
2026-05-17 03:26:01 -04:00
if name == ' dhcp_reservations ' :
rows = [ ]
2026-06-01 01:25:16 -04:00
for res in cfg . get ( ' dhcp_reservations ' , [ ] ) :
row = dict ( res )
row [ ' vlan_name ' ] = res . get ( ' vlan ' , ' - ' )
rows . append ( row )
2026-05-17 03:26:01 -04:00
return rows
if name == ' ddns_providers ' :
2026-05-28 22:50:00 -04:00
ddns = load_ddns ( )
2026-05-17 03:26:01 -04:00
rows = [ ]
for p in ddns . get ( ' providers ' , [ ] ) :
row = dict ( p )
ptype = p . get ( ' provider ' , ' ' ) . lower ( )
if ptype == ' noip ' :
2026-05-26 15:17:36 -04:00
row [ ' credentials ' ] = (
' <div style= " line-height:1.3 " > '
f ' <b>U:</b> { e ( p . get ( " username " , " - " ) ) } <br/> '
' <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 = [ ]
2026-05-28 22:50:00 -04:00
for acct in load_accounts ( ) . get ( ' accounts ' , [ ] ) :
2026-05-17 03:26:01 -04:00
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 ' ) ] ,
2026-05-27 15:29:23 -04:00
key = lambda v : ( v . get ( ' vlan_id ' ) is None , v . get ( ' vlan_id ' ) or 0 )
2026-05-20 17:49:00 -04:00
)
for i , vlan in enumerate ( _wg_sorted ) :
2026-05-20 04:06:50 -04:00
iface = f ' wg { i } '
2026-05-27 15:29:23 -04:00
vlan_display = f ' { iface } (VLAN { vlan . get ( " vlan_id " ) 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-28 22:50:00 -04:00
def load_datasource ( spec ) :
2026-05-26 15:17:36 -04:00
if spec . startswith ( ' live: ' ) :
name = spec [ 5 : ]
2026-05-28 22:50:00 -04:00
if name == ' dhcp_leases ' : return live_dhcp_leases ( )
if name == ' vpn_sessions ' : return live_vpn_sessions ( )
2026-05-26 15:17:36 -04:00
return [ ]
if spec . startswith ( ' config: ' ) :
2026-05-28 22:50:00 -04:00
return config_datasource ( spec [ 7 : ] )
2026-05-26 15:17:36 -04:00
return [ ]
2026-05-28 22:50:00 -04:00
factory . load_datasource = load_datasource
2026-05-26 15:17:36 -04:00
2026-05-21 03:45:14 -04:00
# Live stat helpers =================================================
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def get_dnsmasq_stats ( ) :
2026-05-17 03:26:01 -04:00
stats = { ' queries ' : ' - ' , ' hits ' : ' - ' , ' hit_rate ' : ' - ' ,
' forwarded ' : ' - ' , ' auth ' : ' - ' , ' tcp_peak ' : ' - ' }
2026-05-28 22:50:00 -04:00
out = run ( ' journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null ' )
2026-05-17 03:26:01 -04:00
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 ( ) :
2026-05-28 22:50:00 -04:00
out = run ( " journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c ' is NXDOMAIN ' " )
2026-05-17 03:26:01 -04:00
return out or ' 0 '
def _count_blocked_domains ( ) :
2026-05-28 22:50:00 -04:00
bl_dir = BLOCKLISTS_DIR
2026-05-17 03:26:01 -04:00
try :
total = sum (
2026-05-28 22:50:00 -04:00
int ( run ( f ' wc -l < " { bl_dir } / { f } " ' ) or 0 )
2026-05-26 15:17:36 -04:00
for f in os . listdir ( bl_dir ) if f . endswith ( ' .con ' )
2026-05-17 03:26:01 -04:00
)
return str ( total )
except Exception :
return ' - '
def _bl_last_update ( ) :
2026-05-28 22:50:00 -04:00
bl_dir = BLOCKLISTS_DIR
2026-05-17 03:26:01 -04:00
try :
mtime = max (
os . path . getmtime ( f ' { bl_dir } / { f } ' )
2026-05-26 15:17:36 -04:00
for f in os . listdir ( bl_dir ) if f . endswith ( ' .con ' )
2026-05-17 03:26:01 -04:00
)
2026-05-28 22:50:00 -04:00
return fmt_timestamp ( int ( mtime ) )
2026-05-17 03:26:01 -04:00
except Exception :
return ' - '
2026-05-25 19:59:42 -04:00
def _blocklist_stats_html ( cfg ) :
2026-05-28 22:50:00 -04:00
bl_dir = BLOCKLISTS_DIR
2026-05-23 00:27:37 -04:00
rows = ' '
2026-05-25 19:59:42 -04:00
for bl in cfg . get ( ' dns_blocking ' , { } ) . get ( ' blocklists ' , [ ] ) :
2026-05-26 15:17:36 -04:00
name = e ( bl . get ( ' name ' , ' ' ) )
2026-05-23 00:27:37 -04:00
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-26 15:17:36 -04:00
mtime = int ( os . path . getmtime ( bl_path ) )
2026-05-28 22:50:00 -04:00
size_str = fmt_bytes ( os . path . getsize ( bl_path ) )
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 '
2026-05-26 15:17:36 -04:00
rows + = (
' <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> '
' </tr> '
)
2026-05-23 00:27:37 -04:00
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-06-01 13:29:07 -04:00
DDNS_LOG_MAX = 50
RADIUS_LOG_MAX = 50
RADIUS_LOG_FILE = ' /var/log/freeradius/radius.log '
def _radius_log_tail ( ) :
try :
cfg = load_config ( )
2026-06-01 22:12:11 -04:00
log_max_kb = cfg . get ( ' radius ' , { } ) . get ( ' general ' , { } ) . get ( ' log_max_kb ' , 1024 )
2026-06-01 13:29:07 -04:00
size_kb = os . path . getsize ( RADIUS_LOG_FILE ) / 1024
with open ( RADIUS_LOG_FILE ) as f :
lines = f . readlines ( )
if not lines :
return ' (log is empty) ' , ' '
total = len ( lines )
tail = lines [ - RADIUS_LOG_MAX : ]
shown = len ( tail )
hidden = total - shown
pct = min ( 100 , round ( size_kb / log_max_kb * 100 ) ) if log_max_kb else 0
left = f ' Showing { shown } of { total } lines ( { hidden } not shown) ' if hidden > 0 else f ' Showing { shown } of { total } lines '
right = f ' Log file size: { size_kb : .1f } KB ( { pct } % of max) '
summary = (
2026-06-01 13:51:12 -04:00
' <div id= " radius-log-summary " class= " text-muted " style= " display:flex;justify-content:space-between;margin-top:0.5em; " > '
2026-06-01 13:29:07 -04:00
f ' <span> { left } </span><span> { right } </span></div> '
)
return ' ' . join ( tail ) . strip ( ) , summary
except FileNotFoundError :
return ' (log file not found) ' , ' '
except Exception :
return ' (error reading log) ' , ' '
2026-05-23 16:47:19 -04:00
def _ddns_log_tail ( ) :
2026-05-17 03:26:01 -04:00
log_path = f ' { CONFIGS_DIR } /ddns.log '
try :
2026-05-28 22:50:00 -04:00
log_max_kb = load_ddns ( ) . get ( ' general ' , { } ) . get ( ' log_max_kb ' , 1024 )
2026-05-26 15:17:36 -04:00
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) ' , ' '
2026-05-26 15:17:36 -04:00
total = len ( lines )
tail = lines [ - DDNS_LOG_MAX : ]
shown = len ( tail )
2026-05-23 16:47:19 -04:00
hidden = total - shown
2026-05-26 15:17:36 -04:00
pct = min ( 100 , round ( size_kb / log_max_kb * 100 ) ) if log_max_kb else 0
left = f ' Showing { shown } of { total } lines ( { hidden } not shown) ' if hidden > 0 else f ' Showing { shown } of { total } lines '
right = f ' Log file size: { size_kb : .1f } KB ( { pct } % of max) '
summary = (
' <div class= " text-muted " style= " display:flex;justify-content:space-between;margin-top:0.5em; " > '
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 ( )
2026-05-28 22:50:00 -04:00
last_obtained = f ' Obtained: { relative_time ( mtime ) } ' if mtime else ' '
2026-05-24 23:17:28 -04:00
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 ' )
2026-05-28 22:50:00 -04:00
return f ' Last checked: { relative_time ( mtime ) } '
2026-05-24 12:22:07 -04:00
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-28 22:50:00 -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-28 22:50:00 -04:00
cfg = load_config ( )
2026-05-26 15:17:36 -04:00
net = cfg . get ( ' network_interfaces ' , { } )
2026-05-25 19:59:42 -04:00
dns_blk_gen = cfg . get ( ' dns_blocking ' , { } ) . get ( ' general ' , { } )
2026-05-26 15:17:36 -04:00
dns = cfg . get ( ' upstream_dns ' , { } )
2026-05-25 19:59:42 -04:00
vlans = cfg . get ( ' vlans ' , [ ] )
2026-05-26 15:17:36 -04:00
tokens [ ' GENERAL_WAN_INTERFACE ' ] = str ( net . get ( ' wan_interface ' , ' - ' ) )
tokens [ ' GENERAL_LAN_INTERFACE ' ] = str ( net . get ( ' lan_interface ' , ' - ' ) )
2026-05-28 22:50:00 -04:00
tokens [ ' GENERAL_WAN_STATUS ' ] = iface_status ( net . get ( ' wan_interface ' , ' ' ) )
tokens [ ' GENERAL_LAN_STATUS ' ] = iface_status ( net . get ( ' lan_interface ' , ' ' ) )
2026-05-26 15:17:36 -04:00
tokens [ ' GENERAL_LOG_MAX_KB ' ] = str ( dns_blk_gen . get ( ' log_max_kb ' , ' - ' ) )
2026-05-28 22:50:00 -04:00
sys_ifaces = get_system_interfaces ( )
2026-05-26 15:17:36 -04:00
2026-05-18 14:38:23 -04:00
# 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-28 22:50:00 -04:00
[ { ' value ' : i , ' label ' : f ' { i } - { iface_status ( i ) . title ( ) } ' } for i in sys_ifaces ]
2026-05-20 04:06:50 -04:00
)
2026-05-28 22:50:00 -04:00
iface_data = [ iface_info ( i ) for i in sys_ifaces ]
2026-05-20 04:06:50 -04:00
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-26 15:17:36 -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 ' , ' - ' ) )
tokens [ ' GENERAL_APPLY_ON_SAVE ' ] = ' true ' if session . get ( ' apply_changes_immediately ' , False ) else ' false '
2026-05-22 01:09:23 -04:00
2026-05-26 02:55:10 -04:00
# Queue health fix before building the pending table so it appears immediately.
2026-05-28 22:50:00 -04:00
_level = client_level ( )
2026-05-26 02:55:10 -04:00
if _level > = LEVEL_RANK [ ' administrator ' ] :
try :
import json as _hj
2026-05-28 22:50:00 -04:00
_st = _hj . load ( open ( HEALTH_FILE ) )
2026-05-26 02:55:10 -04:00
_has_problems = any (
item . get ( ' status ' ) == ' problem '
for section in ( ' configurations ' , ' logs ' , ' services ' )
for item in _st . get ( section , [ ] )
)
if _has_problems :
_fix_uuid , _ = _find_cmd_in_queues ( ' fix problems ' )
if _fix_uuid is None :
queue_command ( ' fix problems ' , user = session . get ( ' email_address ' , ' ' ) )
except Exception :
pass
2026-05-30 14:57:33 -04:00
all_groups = load_all_groups ( ) # [(group_dict, [change_dicts])]
_group_uuid_set = { g [ ' uuid ' ] for g , _ in all_groups }
2026-05-26 15:17:36 -04:00
pending_items = get_dashboard_pending ( )
2026-05-22 01:09:23 -04:00
if pending_items :
2026-05-25 21:31:20 -04:00
from collections import defaultdict
2026-05-30 14:57:33 -04:00
_pgroups = defaultdict ( list )
2026-05-25 21:31:20 -04:00
for _uuid , _ts , cmd , user in pending_items :
2026-05-30 14:57:33 -04:00
_pgroups [ cmd ] . append ( ( _uuid , user ) )
2026-05-22 01:09:23 -04:00
rows = ' '
2026-05-30 14:57:33 -04:00
for cmd , entries in _pgroups . items ( ) :
2026-05-26 00:22:36 -04:00
users = ' , ' . join ( sorted ( { u for _ , u in entries if u and u != ' unknown ' } ) )
2026-05-30 14:57:33 -04:00
snap_uuids = [ _uuid for _uuid , _ in entries if _uuid in _group_uuid_set ]
2026-05-26 01:32:46 -04:00
if snap_uuids :
2026-05-26 01:29:35 -04:00
req_tags = ' ' . join (
f ' <span class= " tag " data-tooltip= " { _uuid } " data-uuid= " { _uuid } " > '
f ' <span class= " tl-full " > { _uuid [ : 8 ] } </span> '
f ' <span class= " tl-short " > { _uuid [ : 8 ] } </span> '
f ' <span class= " tl-min " > { _uuid [ : 8 ] } </span> '
2026-05-26 15:17:36 -04:00
' </span> '
2026-05-26 01:32:46 -04:00
for _uuid in snap_uuids
2026-05-26 01:29:35 -04:00
)
req_cell = f ' <td class= " table-cell " ><div class= " tag-list " > { req_tags } </div></td> '
2026-05-26 01:32:46 -04:00
else :
req_cell = ' <td class= " table-cell " >-</td> '
2026-05-26 15:17:36 -04:00
rows + = (
' <tr> '
f ' <td class= " table-cell " > { e ( cmd ) } </td> '
f ' <td class= " table-cell " > { e ( users ) } </td> '
f ' { req_cell } '
' </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> '
2026-05-26 15:17:36 -04:00
2026-05-25 21:31:20 -04:00
tokens [ ' PENDING_ACTIONS_HTML ' ] = pending_html
2026-05-29 23:10:04 -04:00
tokens [ ' NO_PENDING ' ] = ' true ' if not pending_items else ' '
tokens [ ' NO_DISMISSIBLE_PENDING ' ] = ' true ' if not any ( c != ' fix problems ' for _ , _ , c , _ in pending_items ) else ' '
2026-05-26 15:46:41 -04:00
tokens [ ' APPLY_WARNING ' ] = (
2026-05-30 15:16:36 -04:00
f ' <span style= " color:var(--warning) " ><p> { _load_icon ( " arrow-left " ) } <strong>Applying actions will briefly disrupt connections as network services are restarted.</strong></p></span> '
2026-05-26 15:46:41 -04:00
if pending_items else ' '
)
2026-05-26 00:28:04 -04:00
done_ts_map = get_done_timestamps ( )
2026-05-30 14:57:33 -04:00
if all_groups :
2026-05-26 00:58:51 -04:00
_no_revert = set ( )
2026-05-30 14:57:33 -04:00
for g , _ in all_groups :
if g [ ' reverts_group ' ] :
_no_revert . add ( g [ ' uuid ' ] )
_no_revert . add ( g [ ' reverts_group ' ] )
2026-05-25 16:38:08 -04:00
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-30 14:57:33 -04:00
for g , changes in all_groups :
_uuid = g [ ' uuid ' ]
2026-05-26 00:28:04 -04:00
applied_ts = done_ts_map . get ( _uuid )
2026-05-26 15:17:36 -04:00
dt_str = datetime . fromtimestamp ( applied_ts ) . strftime ( ' % Y- % m- %d % H: % M ' ) if applied_ts else ' - '
2026-05-30 14:57:33 -04:00
all_before_null = all ( c [ ' before ' ] is None for c in changes )
all_after_null = all ( c [ ' after ' ] is None for c in changes )
if g [ ' reverts_group ' ] :
verb = ' Reverted '
elif all_before_null :
verb = ' Added '
elif all_after_null :
verb = ' Deleted '
else :
verb = ' Edited '
item = g . get ( ' item_value ' ) or ' '
summary = f ' { verb } { g [ " parent_path " ] } : { item } ' if item else f ' { verb } { g [ " parent_path " ] } '
2026-05-26 15:17:36 -04:00
snap_tag = (
f ' <div class= " tag-list " ><span class= " tag " data-tooltip= " { e ( _uuid ) } " data-uuid= " { e ( _uuid ) } " > '
f ' <span class= " tl-full " > { e ( _uuid [ : 8 ] ) } </span> '
f ' <span class= " tl-short " > { e ( _uuid [ : 8 ] ) } </span> '
f ' <span class= " tl-min " > { e ( _uuid [ : 8 ] ) } </span> '
' </span></div> '
)
2026-05-30 14:57:33 -04:00
snap_user = e ( g . get ( ' user ' , ' ' ) )
2026-05-26 15:17:36 -04:00
_cb_attrs = ' disabled title= " Cannot revert " ' if _uuid in _no_revert else ' '
hist_rows + = (
f ' <tr class= " row-expandable " data-uuid= " { e ( _uuid ) } " { _hist_onclick } > '
f ' <td class= " table-cell " ><input type= " checkbox " name= " selected_uuids " value= " { e ( _uuid ) } " { _cb_attrs } /></td> '
f ' <td class= " table-cell " > { e ( dt_str ) } </td> '
2026-05-30 14:57:33 -04:00
f ' <td class= " table-cell " > { e ( summary ) } </td> '
f ' <td class= " table-cell " > { build_snap_val ( changes ) } </td> '
2026-05-26 15:17:36 -04:00
f ' <td class= " table-cell " > { snap_tag } </td> '
f ' <td class= " table-cell " > { snap_user } </td> '
' </tr> '
2026-05-30 14:57:33 -04:00
f ' { snap_expand_row ( changes , 6 ) } '
2026-05-26 15:17:36 -04:00
)
2026-05-25 21:31:20 -04:00
select_all = (
' <input type= " checkbox " '
2026-05-26 00:58:51 -04:00
' onchange= " document.querySelectorAll( \' [name=selected_uuids]:not(:disabled) \' ).forEach(c=>c.checked=this.checked) " /> '
2026-05-25 21:31:20 -04:00
)
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> '
2026-05-30 14:57:33 -04:00
' <th class= " table-header " >Change</th> '
' <th class= " table-header " >Fields</th> '
2026-05-31 23:17:30 -04:00
' <th class= " table-header " >Change ID</th> '
2026-05-25 16:38:08 -04:00
' <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-26 15:17:36 -04:00
2026-05-25 16:38:08 -04:00
tokens [ ' CHANGE_HISTORY_HTML ' ] = history_html
2026-05-30 14:57:33 -04:00
tokens [ ' NO_HISTORY ' ] = ' true ' if not all_groups else ' '
2026-05-25 16:38:08 -04:00
2026-05-17 03:26:01 -04:00
servers = dns . get ( ' upstream_servers ' , [ ] )
2026-05-26 15:17:36 -04:00
tokens [ ' DNS_STRICT_ORDER ' ] = ' true ' if dns . get ( ' strict_order ' ) else ' false '
tokens [ ' DNS_CACHE_SIZE ' ] = str ( dns . get ( ' cache_size ' , ' - ' ) )
2026-05-17 03:26:01 -04:00
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 ' - '
2026-05-26 15:17:36 -04:00
tokens [ ' STAT_VLAN_COUNT ' ] = str ( len ( non_vpn_vlans ) )
2026-05-28 22:50:00 -04:00
tokens [ ' STAT_LEASE_COUNT ' ] = str ( len ( live_dhcp_leases ( ) ) )
2026-05-17 03:26:01 -04:00
filter_opts = ' <option value= " all " >All VLANs</option> ' + ' ' . join (
f ' <option value= " { e ( n ) } " > { e ( n ) } </option> ' for n in vlan_names
)
2026-05-26 15:17:36 -04:00
tokens [ ' VLAN_FILTER_OPTIONS ' ] = filter_opts
tokens [ ' VLAN_NAMES_AS_OPTIONS ' ] = json . dumps ( [ { ' value ' : n , ' label ' : n } for n in vlan_names ] )
tokens [ ' VPN_VLAN_COUNT ' ] = str ( sum ( 1 for v in vlans if v . get ( ' is_vpn ' ) ) )
2026-05-31 22:29:05 -04:00
_res_ips_by_vlan = { }
_res_hosts_by_vlan = { }
for _v in vlans :
_vn = _v . get ( ' name ' , ' ' )
if not _vn :
continue
2026-06-01 01:25:16 -04:00
_vlan_res = [ r for r in cfg . get ( ' dhcp_reservations ' , [ ] ) if r . get ( ' vlan ' ) == _vn ]
_res_ips_by_vlan [ _vn ] = [ r [ ' ip ' ] for r in _vlan_res if r . get ( ' ip ' ) and r [ ' ip ' ] != ' dynamic ' ]
_res_hosts_by_vlan [ _vn ] = [ r [ ' hostname ' ] for r in _vlan_res if r . get ( ' hostname ' ) ]
2026-05-31 22:29:05 -04:00
tokens [ ' RESERVATION_IPS_BY_VLAN_JSON ' ] = json . dumps ( _res_ips_by_vlan )
tokens [ ' RESERVATION_HOSTNAMES_BY_VLAN_JSON ' ] = json . dumps ( _res_hosts_by_vlan )
2026-06-01 01:44:58 -04:00
tokens [ ' VLAN_SUBNET_INFO_JSON ' ] = json . dumps ( {
v . get ( ' name ' , ' ' ) : { ' subnet ' : v . get ( ' subnet ' , ' ' ) , ' prefix ' : v . get ( ' subnet_mask ' , 0 ) }
for v in vlans if v . get ( ' name ' ) and v . get ( ' subnet ' )
} )
2026-05-31 22:01:59 -04:00
tokens [ ' EXISTING_VLAN_IDS_JSON ' ] = json . dumps ( [ v . get ( ' vlan_id ' ) for v in vlans ] )
tokens [ ' EXISTING_VLAN_NAMES_JSON ' ] = json . dumps ( [ v . get ( ' name ' ) for v in vlans ] )
2026-05-29 21:35:26 -04:00
_dv = next ( ( v for v in vlans if v . get ( ' radius_default ' ) ) , None )
tokens [ ' RADIUS_DEFAULT_VLAN ' ] = f ' " { _dv [ " name " ] } " (VLAN { _dv [ " vlan_id " ] } ) ' if _dv else ' none set '
2026-05-30 16:25:46 -04:00
try :
tokens [ ' RADIUS_SECRET ' ] = open ( f ' { CONFIGS_DIR } /.radius-secret ' ) . read ( ) . strip ( )
except OSError :
2026-05-30 16:39:12 -04:00
tokens [ ' RADIUS_SECRET ' ] = ' (Generation is pending - visit Actions to apply generation command) '
2026-06-01 22:12:11 -04:00
_fr = cfg . get ( ' radius ' , { } )
2026-06-01 13:29:07 -04:00
_fr_opts = _fr . get ( ' options ' , { } )
_fr_gen = _fr . get ( ' general ' , { } )
tokens [ ' RADIUS_MAC_FORMAT ' ] = _fr_opts . get ( ' mac_format ' , ' aabbccddeeff ' )
tokens [ ' RADIUS_APPLY_TO ' ] = _fr_opts . get ( ' apply_to ' , ' all ' )
tokens [ ' RADIUS_LOGGING ' ] = ' true ' if _fr_gen . get ( ' logging ' , False ) else ' '
tokens [ ' RADIUS_GEN_LOG_MAX_KB ' ] = str ( _fr_gen . get ( ' log_max_kb ' , 1024 ) )
tokens [ ' RADIUS_LOG_TAIL ' ] , tokens [ ' RADIUS_LOG_SUMMARY ' ] = _radius_log_tail ( )
2026-05-26 15:17:36 -04:00
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
2026-05-28 22:50:00 -04:00
ddns = load_ddns ( )
2026-05-24 02:28:52 -04:00
ddns_gen = ddns . get ( ' general ' , { } )
2026-05-26 15:17:36 -04:00
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-26 15:17:36 -04:00
tokens [ ' DDNS_GEN_LOG_MAX_KB ' ] = str ( ddns_gen . get ( ' log_max_kb ' , 1024 ) )
2026-05-24 02:28:52 -04:00
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 ' ]
2026-05-26 15:17:36 -04:00
_dig_svc = [ s [ ' url ' ] for s in _ip_check if s . get ( ' type ' ) == ' dig ' ]
2026-05-24 03:02:10 -04:00
tokens [ ' STAT_IP_CHECK_TOTAL ' ] = str ( len ( _ip_check ) )
2026-05-26 15:17:36 -04:00
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 ' ) ] ,
2026-05-27 15:29:23 -04:00
key = lambda v : ( v . get ( ' vlan_id ' ) is None , v . get ( ' vlan_id ' ) or 0 )
2026-05-20 17:49:00 -04:00
)
2026-05-20 04:06:50 -04:00
tokens [ ' VPN_VLAN_OPTIONS ' ] = json . dumps ( [
2026-05-27 15:29:23 -04:00
{ ' value ' : v . get ( ' name ' , ' ' ) , ' label ' : f ' wg { i } (VLAN { v . get ( " vlan_id " ) or " ? " } ) ' }
2026-05-20 04:06:50 -04:00
for i , v in enumerate ( wg_vlans_list )
] )
2026-05-26 15:17:36 -04:00
wg_vlan = wg_vlans_list [ 0 ] if wg_vlans_list else { }
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 ' , ' ' ) )
2026-05-31 02:17:25 -04:00
tokens [ ' VPN_DNS_SERVER ' ] = str ( overrides . get ( ' dns_servers ' , ' ' ) )
2026-05-18 14:38:23 -04:00
tokens [ ' VPN_MTU ' ] = str ( overrides . get ( ' mtu ' , ' ' ) )
2026-05-26 15:17:36 -04:00
2026-05-18 14:38:23 -04:00
# 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 :
2026-05-26 15:17:36 -04:00
wg_net = _ipaddress . IPv4Network (
2026-05-18 14:38:23 -04:00
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-28 22:50:00 -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 '
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
dns_stats = get_dnsmasq_stats ( )
2026-05-26 15:17:36 -04:00
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 ' , ' ' )
2026-05-17 03:26:01 -04:00
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
# Layout renderer ===================================================
2026-05-17 03:26:01 -04:00
2026-05-29 22:16:56 -04:00
def render_layout ( view_id , content_html , tokens , page_name = None ) :
2026-05-26 15:17:36 -04:00
css = _load_css ( )
2026-05-28 22:50:00 -04:00
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-28 22:50:00 -04:00
navbar_html = build_navbar ( view_id , level , tokens , pending_alert = has_pending_alert )
2026-05-26 15:17:36 -04:00
footer_html = f ' <footer class= " footer " > { WEB_APP_DISPLAY_NAME } </footer> '
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
page_hash = config_hash ( )
lan_iface = e ( tokens . get ( ' GENERAL_LAN_INTERFACE ' , ' ' ) )
vpn_count = tokens . get ( ' VPN_VLAN_COUNT ' , ' 0 ' )
2026-05-20 04:06:50 -04:00
current_user = session . get ( ' email_address ' , ' ' )
pending = get_pending_entries ( )
2026-05-26 02:46:47 -04:00
my_uuid = next ( ( u for u , t , c , usr in pending if usr == current_user and c != ' fix problems ' ) , None )
2026-05-20 04:06:50 -04:00
2026-05-28 22:52:01 -04:00
secs = _seconds_until_next_run ( )
2026-05-20 04:06:50 -04:00
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 )
2026-05-26 01:23:05 -04:00
_display_user = ' Another user ' if o_user in ( ' unknown ' , ' ' ) else e ( o_user )
2026-05-20 04:06:50 -04:00
if locked and lock_mtime and o_ts < lock_mtime :
2026-05-26 01:23:05 -04:00
text = f ' { _display_user } \' s changes are being applied now... '
2026-05-26 15:17:36 -04:00
cls = ' info-bar-warning info-bar-running '
2026-05-20 04:06:50 -04:00
else :
timing = _format_timing ( secs )
2026-05-26 15:17:36 -04:00
text = f ' { _display_user } has pending changes which will be applied { timing } . ' if timing else f ' { _display_user } has pending changes. The processing service is not running. '
cls = ' info-bar-warning '
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 = ' '
2026-05-26 01:23:05 -04:00
if level > = LEVEL_RANK [ ' viewer ' ] :
try :
import json as _j
2026-05-28 22:50:00 -04:00
st = _j . load ( open ( HEALTH_FILE ) )
2026-05-30 15:04:55 -04:00
problems = [ ]
2026-05-26 01:23:05 -04:00
for section in ( ' configurations ' , ' logs ' ) :
for item in st . get ( section , [ ] ) :
if item . get ( ' status ' ) == ' problem ' :
2026-05-30 15:04:55 -04:00
problems . append ( e ( item . get ( ' detail ' , item . get ( ' name ' , ' ' ) ) ) )
2026-05-26 01:23:05 -04:00
for item in st . get ( ' services ' , [ ] ) :
2026-05-22 01:09:23 -04:00
if item . get ( ' status ' ) == ' problem ' :
2026-05-26 15:17:36 -04:00
name = item . get ( ' name ' , ' ' )
2026-05-26 01:23:05 -04:00
utype = ' timer ' if name . endswith ( ' .timer ' ) else ' service ' if name . endswith ( ' .service ' ) else ' unit '
exp_parts , act_parts = [ ] , [ ]
if not item . get ( ' active_ok ' ) :
exp_parts . append ( item . get ( ' expected_active ' , ' active ' ) )
act_parts . append ( item . get ( ' active ' , ' unknown ' ) )
if not item . get ( ' enabled_ok ' ) :
exp_parts . append ( item . get ( ' expected_enabled ' , ' enabled ' ) )
act_parts . append ( item . get ( ' enabled ' , ' unknown ' ) )
2026-05-30 15:04:55 -04:00
problems . append ( e (
2026-05-26 15:17:36 -04:00
f " The { utype } ` { name } ` is expected to be "
f " { ' and ' . join ( exp_parts ) } but is { ' and ' . join ( act_parts ) } . "
2026-05-30 15:04:55 -04:00
) )
has_problems = bool ( problems )
2026-05-26 01:23:05 -04:00
fix_suffix = ' '
2026-05-26 15:17:36 -04:00
fix_uuid = None
2026-05-26 01:23:05 -04:00
if has_problems :
if level < LEVEL_RANK [ ' administrator ' ] :
fix_suffix = ' Please contact an administrator. '
2026-05-25 23:57:34 -04:00
else :
2026-05-26 01:23:05 -04:00
fix_uuid , fix_ts = _find_cmd_in_queues ( ' fix problems ' )
if _apply_changes_immediately ( ) :
if _is_locked ( ) :
mtime = _lock_mtime ( )
fix_suffix = ( ' Fix is being applied now... ' if fix_ts and mtime and fix_ts < mtime
else ' Fix will be applied on the next run. ' )
else :
2026-05-28 22:52:01 -04:00
timing = _format_timing ( _seconds_until_next_run ( ) )
2026-05-26 01:23:05 -04:00
fix_suffix = ( f ' Fix will be applied { timing } . ' if timing
else ' Fix pending. The processing service is not running. ' )
else :
2026-05-26 15:21:03 -04:00
fix_suffix = ( ' Fix pending. Click <strong>Apply Now</strong> below to fix. '
2026-05-27 22:04:04 -04:00
if view_id == ' actions ' else
2026-05-26 15:21:03 -04:00
' Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix. ' )
2026-05-30 15:04:55 -04:00
if problems :
2026-05-26 01:23:05 -04:00
problems_list = ( ' <ul style= " margin:0.25em 0;padding-left:1.25em " > '
2026-05-30 15:04:55 -04:00
+ ' ' . join ( f ' <li> { d } </li> ' for d in problems )
2026-05-26 01:23:05 -04:00
+ ' </ul> ' )
uuid_attr = ( f ' data-health-uuid= " { e ( fix_uuid ) } " '
2026-05-26 03:04:58 -04:00
if fix_uuid and _entry_ts_from_queue ( fix_uuid ) is not None else ' ' )
2026-05-26 01:23:05 -04:00
fix_html = ( f ' <div style= " margin-top:0.5em " { uuid_attr } > { fix_suffix } </div> '
if fix_suffix else ' ' )
content = ( ' <div style= " width:100 % " > '
' <div style= " font-weight:600;margin-bottom:0.25em " >Health check - problems found:</div> '
+ problems_list + fix_html
+ ' </div> ' )
2026-05-30 15:04:55 -04:00
problem_bars + = f ' <div class= " info-bar info-bar-danger " > { content } </div> \n '
2026-05-26 01:23:05 -04:00
except Exception :
pass
2026-05-22 01:09:23 -04:00
2026-05-26 00:07:35 -04:00
pending_bar = ' '
2026-05-27 22:04:04 -04:00
if has_pending_alert and not problem_bars and view_id != ' actions ' :
2026-05-26 15:17:36 -04:00
pending_bar = (
' <div class= " info-bar info-bar-warning " > '
' <span>You have actions pending. Please visit the <strong>Actions</strong> page.</span> '
' </div> \n '
)
return (
' <!DOCTYPE html> \n <html lang= " en " > \n <head> \n '
' <meta charset= " UTF-8 " /> \n '
' <meta name= " viewport " content= " width=device-width, initial-scale=1.0 " /> \n '
f ' <title> { WEB_APP_DISPLAY_NAME } </title> \n '
f ' <style> { css } </style> \n '
' </head> \n <body> \n '
f ' { titlebar_html } \n '
f ' { navbar_html } \n '
f ' <main class= " main-content " > \n { pending_bar } { problem_bars } { other_bars } { content_html } \n </main> \n '
f ' { footer_html } \n '
2026-05-28 22:50:00 -04:00
f ' <script>var CONFIG_HASH= " { page_hash } " ;var LAN_IFACE= " { lan_iface } " ;var VPN_VLAN_COUNT= { vpn_count } ;var APPLY_UUID= { json . dumps ( my_uuid ) } ;</script> \n '
2026-05-29 22:16:56 -04:00
f ' <script> { _inline_js ( page_name ) } </script> \n '
2026-05-26 15:17:36 -04:00
' </body> \n </html> '
)
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def build_navbar ( active_view , level , tokens , pending_alert = False ) :
navbar_data = load_json ( NAVBAR_FILE )
2026-05-17 03:26:01 -04:00
left , right = [ ] , [ ]
for item in navbar_data . get ( ' items ' , [ ] ) :
2026-05-26 15:17:36 -04:00
req = item . get ( ' client_requirement ' )
2026-05-17 03:26:01 -04:00
align = item . get ( ' align ' , ' left ' )
2026-05-28 22:50:00 -04:00
if not passes ( req , level ) :
2026-05-17 03:26:01 -04:00
continue
2026-05-28 22:50:00 -04:00
frag = build_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 )
2026-05-26 15:17:36 -04:00
return (
' <nav class= " nav-bar " > '
f ' <div class= " nav-left " > { " " . join ( left ) } </div> '
f ' <div class= " nav-right " > { " " . join ( right ) } </div> '
' </nav> '
)
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def build_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 )
2026-05-26 15:17:36 -04:00
t = item . get ( ' type ' , ' ' )
2026-05-17 03:26:01 -04:00
if t in ( ' nav_item ' , ' nav_action ' ) :
2026-05-26 15:17:36 -04:00
label = e ( item . get ( ' label ' , ' ' ) )
map_to = item . get ( ' map_to ' , ' ' )
action = item . get ( ' action ' , ' ' )
2026-05-17 03:26:01 -04:00
is_active = ' active ' if map_to and map_to == active_view else ' '
2026-05-27 22:04:04 -04:00
pending = ' nav-item-pending ' if pending_alert and map_to == ' actions ' else ' '
2026-05-26 15:17:36 -04:00
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-26 15:17:36 -04:00
return (
f ' <form method= " post " action= " /action/ { e ( action ) } " class= " form-inline " > '
f ' <button type= " submit " class= " { cls } " > { label } </button></form> '
)
2026-05-17 03:26:01 -04:00
if map_to :
2026-05-27 22:04:04 -04:00
return f ' <a href= " / { e ( map_to ) } " class= " { cls } " > { label } </a> '
2026-05-17 03:26:01 -04:00
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 '
2026-05-26 15:17:36 -04:00
label = e ( raw_label )
2026-05-17 03:26:01 -04:00
children = ' '
for child in item . get ( ' items ' , [ ] ) :
child_req = child . get ( ' client_requirement ' , req )
2026-05-28 22:50:00 -04:00
if not passes ( child_req , level ) :
2026-05-17 03:26:01 -04:00
continue
2026-05-28 22:50:00 -04:00
children + = build_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 ' '
2026-05-26 15:17:36 -04:00
return (
' <div class= " nav-menu " > '
f ' <button class= " nav-item nav-menu-trigger " aria-haspopup= " true " > { label } </button> '
f ' <div class= " nav-dropdown " > { children } </div> '
' </div> '
)
2026-05-17 03:26:01 -04:00
return ' '
2026-05-21 03:45:14 -04:00
# Inline JavaScript =================================================
2026-05-17 03:26:01 -04:00
2026-05-29 22:16:56 -04:00
def _inline_js ( page_name = None ) :
2026-05-31 00:22:39 -04:00
big_validate_js = factory . build_big_validate ( )
2026-05-26 15:17:36 -04:00
try :
2026-05-28 22:50:00 -04:00
with open ( COMMON_JS_FILE ) as f :
2026-05-28 09:28:26 -04:00
app_js = f . read ( )
2026-05-26 15:17:36 -04:00
except Exception :
2026-05-28 09:28:26 -04:00
app_js = ' '
2026-05-29 22:16:56 -04:00
page_js = ' '
if page_name :
page_js_path = os . path . join ( PAGES_DIR , page_name , ' page.js ' )
try :
with open ( page_js_path ) as f :
page_js = f . read ( )
except Exception :
pass
2026-05-31 00:22:39 -04:00
return big_validate_js + ' \n ' + app_js + ( ' \n ' + page_js if page_js else ' ' )
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 ( ) :
2026-05-28 22:50:00 -04:00
return serve_view ( ' overview ' )
2026-05-17 03:26:01 -04:00
2026-05-27 22:04:04 -04:00
@bp.route ( ' /<page_name> ' )
def view ( page_name ) :
2026-05-28 22:50:00 -04:00
return serve_view ( page_name )
2026-05-17 03:26:01 -04:00
2026-05-28 22:50:00 -04:00
def serve_view ( page_name ) :
view_def = load_json ( os . path . join ( PAGES_DIR , page_name , ' content.json ' ) )
2026-05-17 03:26:01 -04:00
if view_def is None :
from flask import abort
abort ( 404 )
view_req = view_def . get ( ' client_requirement ' )
2026-05-28 22:50:00 -04:00
level = client_level ( )
if not passes ( view_req , level ) :
2026-05-27 22:04:04 -04:00
return redirect ( ' /overview ' if level > 0 else ' /accountlogin ' )
2026-05-17 03:26:01 -04:00
tokens = collect_tokens ( )
2026-05-30 16:39:12 -04:00
if page_name == ' radius ' and not os . path . exists ( f ' { CONFIGS_DIR } /.radius-secret ' ) :
from config_utils import queue_command
queue_command ( ' gen radius ' )
2026-05-17 03:26:01 -04:00
flash_html = ' '
for category , message in get_flashed_messages ( with_categories = True ) :
2026-05-26 15:17:36 -04:00
variant = { ' error ' : ' danger ' , ' warning ' : ' warning ' , ' success ' : ' success ' } . get ( category , ' info ' )
2026-05-17 03:26:01 -04:00
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
2026-05-28 22:50:00 -04:00
content_html = flash_html + build_items ( view_def . get ( ' items ' , [ ] ) , tokens , view_req )
2026-05-29 22:16:56 -04:00
return render_layout ( page_name , content_html , tokens , page_name = page_name )