linuxrouter/docker/routlin-dash/app/view_page.py
2026-05-21 01:34:42 -04:00

2377 lines
99 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
import validation as validate
from datetime import datetime, timezone
from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, PRODUCT_DISPLAY_NAME
bp = Blueprint('view_page', __name__)
DATA_DIR = '/data'
CONFIGS_DIR = '/configs'
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
# -- Access level --------------------------------------------------------------
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
# -- File loaders --------------------------------------------------------------
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 {}
def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json')
def _load_ddns(): return _load_json(f'{CONFIGS_DIR}/ddns.json')
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 ''
# -- Shell helper --------------------------------------------------------------
def _run(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
def _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():
_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,
}
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'
def _resolve_iface(vlan, core):
"""Compute interface name from is_vpn + derived vlan_id + general.lan_interface."""
if vlan.get('is_vpn'):
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
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)
return f'wg{idx}'
lan = core.get('general', {}).get('lan_interface', 'eth0')
vid = validate.derive_vlan_id(vlan.get('subnet', ''), vlan.get('subnet_mask', 24)) or 1
return lan if vid == 1 else f'{lan}.{vid}'
# -- Live data loaders ---------------------------------------------------------
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
for vlan in _load_core().get('vlans', []):
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', 24)
if not subnet:
continue
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False):
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 '-'
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'
# -- Config data loaders -------------------------------------------------------
def _config_datasource(name):
core = _load_core()
vlans = core.get('vlans', [])
if name == 'interfaces':
gen = core.get('general', {})
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)},
]
if name == 'banned_ips':
return core.get('banned_ips', [])
if name == 'host_overrides':
return core.get('host_overrides', [])
if name == 'blocklists':
rows = []
for bl in core.get('blocklists', []):
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':
bl_desc = {b['name']: b.get('description', b['name']) for b in core.get('blocklists', []) if 'name' in b}
rows = []
for v in sorted(vlans, key=lambda x: validate.derive_vlan_id(x.get('subnet', ''), x.get('subnet_mask', 24)) or 0):
row = {k: v.get(k) for k in ('name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
row['vlan_id'] = validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24))
row['interface'] = _resolve_iface(v, core)
row['use_blocklists'] = json.dumps([
{'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', [])
])
rows.append(row)
return rows
if name == 'inter_vlan_exceptions':
return core.get('inter_vlan_exceptions', [])
if name == 'port_forwarding':
return core.get('port_forwarding', [])
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':
row['credentials'] = f"U: {p.get('username', '-')}"
elif ptype in ('cloudflare', 'duckdns'):
tok = p.get('api_token', '')
row['credentials'] = f'API Token: {tok[:8]}' if tok else '(not set)'
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
if name == 'vpn_peers':
rows = []
_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):
iface = f'wg{i}'
vlan_display = f'{iface} (VLAN {validate.derive_vlan_id(vlan.get("subnet", ""), vlan.get("subnet_mask", 24)) or "?"})'
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)
return rows
return []
# -- Live stat helpers ---------------------------------------------------------
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 '-'
def _ddns_log_tail(n=50):
log_path = f'{CONFIGS_DIR}/ddns.log'
try:
with open(log_path) as f:
lines = f.readlines()
return ''.join(lines[-n:]).strip() or '(log is empty)'
except FileNotFoundError:
return '(log file not found)'
except Exception:
return '(error reading log)'
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():
try:
best_ip, best_mtime = '', 0
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
return best_ip
except Exception:
return ''
def _public_ip_info(ddns_cfg):
"""Return (ip_str, domains_sub, next_interval_str) for stat cards."""
script = f'{CONFIGS_DIR}/ddns.py'
enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
all_hosts = []
for p in enabled_p:
all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
domains_sub = ', '.join(all_hosts)
interval_secs = _parse_interval_to_seconds(ddns_cfg.get('general', {}).get('timer_interval', ''))
next_interval = '-'
# Path 1: timer healthy and within interval -> use cached IP
if interval_secs and enabled_p:
status = _run(f'python3 {script} --status 2>/dev/null')
if status:
is_enabled = '; enabled' in status
is_active = 'active (waiting)' in status or 'active (running)' in status
remaining = _parse_time_remaining(status)
if remaining is not None:
next_interval = _fmt_seconds(remaining)
if is_enabled and is_active and remaining is not None and remaining < interval_secs:
ip = _read_cached_ip()
if ip:
return ip, domains_sub, next_interval
# Path 2: live fetch
ip = _run(f'python3 {script} --getip 2>/dev/null')
if ip and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
return ip, domains_sub, next_interval
# Path 3: offline
return 'DDNS Offline', domains_sub, next_interval
def _vpn_info():
for vlan in _load_core().get('vlans', []):
if 'vpn_information' in vlan:
return vlan['vpn_information']
return {}
# -- Token collection ----------------------------------------------------------
def collect_tokens():
tokens = {}
core = _load_core()
gen = core.get('general', {})
dns = core.get('upstream_dns', {})
vlans = core.get('vlans', [])
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
tokens['GENERAL_WAN_STATUS'] = _iface_status(gen.get('wan_interface', ''))
tokens['GENERAL_LAN_STATUS'] = _iface_status(gen.get('lan_interface', ''))
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
sys_ifaces = _get_system_interfaces()
# Always include currently-configured values so dropdowns are never blank.
for configured in [gen.get('wan_interface', ''), gen.get('lan_interface', '')]:
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(
[{'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')
)
tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed')))
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
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 '-'
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
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])
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) for v in vlans])
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(len(core.get('blocklists', [])))
ddns = _load_ddns()
tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
_ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'}
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
{'value': p, 'label': _ddns_labels.get(p, p.title())}
for p in validate.VALID_DDNS_PROVIDERS
])
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)
)
tokens['VPN_VLAN_OPTIONS'] = json.dumps([
{'value': v.get('name', ''), 'label': f'wg{i} (VLAN {validate.derive_vlan_id(v.get("subnet", ""), v.get("subnet_mask", 24)) or "?"})'}
for i, v in enumerate(wg_vlans_list)
])
wg_vlan = wg_vlans_list[0] if wg_vlans_list else {}
vpn = wg_vlan.get('vpn_information', {})
overrides = vpn.get('explicit_overrides', {})
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'] = ''
ip_str, sub_str, next_interval = _public_ip_info(ddns)
tokens['STAT_PUBLIC_IP'] = ip_str
tokens['STAT_DDNS_HOSTNAME'] = sub_str
tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval
tokens['DDNS_LOG_TAIL'] = _ddns_log_tail()
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]
)
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', ''))}
for bl in core.get('blocklists', [])
])
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)'},
])
return tokens
# -- HTML helpers --------------------------------------------------------------
def e(text):
return html_mod.escape(str(text))
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
# -- Content item renderers ----------------------------------------------------
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>'
if t == 'p':
text = e(apply_tokens(item.get('text', ''), tokens))
link = item.get('link')
if link:
href = e(apply_tokens(link.get('action', '#'), tokens))
ltext = e(apply_tokens(link.get('text', ''), tokens))
return f'<p>{text} <a href="{href}" class="auth-link">{ltext}</a></p>'
return f'<p>{text}</p>'
if t == 'spacer':
return '<div class="spacer"></div>'
if t == 'divider':
return '<hr class="divider">'
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
cls_map = {
'button_primary': 'btn-primary',
'button_secondary': 'btn-secondary',
'button_danger': 'btn-danger',
'button_ghost': 'btn-ghost',
}
cls = cls_map[t]
extra = item.get('class', '')
if extra:
cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens))
action = e(apply_tokens(item.get('action', '#'), tokens))
disabled = ' disabled' if item.get('disabled') else ''
if item.get('method', '').lower() == 'post':
return (f'<form method="post" action="{action}" style="display:inline">'
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>')
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
if t == 'button_cancel':
text = e(apply_tokens(item.get('text', 'Cancel'), tokens))
return f'<button type="button" class="btn btn-secondary btn-cancel" disabled>{text}</button>'
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':
label = e(apply_tokens(item.get('label', ''), tokens))
value = e(apply_tokens(item.get('value', ''), tokens))
sub = e(apply_tokens(item.get('sub', ''), tokens))
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
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 ''
style = ' style="display:none"' if item.get('hidden') else ''
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)
return f'<div class="card"{id_attr}{style}>{header}<div class="card-body">{body}</div></div>'
if t == 'field_status':
label = e(item.get('label', ''))
raw = apply_tokens(item.get('value', ''), tokens).upper()
badge_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown'))
return (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>')
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':
text = e(apply_tokens(item.get('text', ''), tokens))
return f'<pre class="pre-block">{text}</pre>'
if t == 'credential_fields':
psel = e(item.get('provider_select', 'provider'))
return (
f'<div class="credential-fields" data-provider-select="{psel}">'
f'<div class="cred-group-token" style="display:none">'
f'<div class="form-group"><label class="form-label">API Token</label>'
f'<input type="text" name="api_token" class="form-input"></div>'
f'</div>'
f'<div class="cred-group-noip" style="display:none">'
f'<div class="form-group"><label class="form-label">Username</label>'
f'<input type="text" name="username" class="form-input"></div>'
f'<div class="form-group"><label class="form-label">Password</label>'
f'<input type="password" name="password" class="form-input"></div>'
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)
hash_field = f'<input type="hidden" name="config_hash" value="{e(core_hash())}">'
originals = json.dumps(_collect_form_originals(item.get('items', []), tokens))
orig_field = f'<input type="hidden" name="original_values" value="{e(originals)}">'
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))
return f'<input type="hidden" name="{name}" value="{value}">'
if t == 'field':
return _render_field(item, tokens)
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">'
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input">'
f'<span class="subnet-sep">/</span>'
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input">'
f'<span class="subnet-dotted">{e(dotted)}</span>'
f'</div>'
f'<p class="form-hint field-dyn-hint" style="display:none"></p>'
f'</div>'
)
if t == 'editable_list':
return _render_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
filter_col = item.get('filter_col', '')
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
if t == 'button_row':
inner = render_items(item.get('items', []), tokens, req)
return f'<div class="button-row">{inner}</div>'
if t == 'table':
return _render_table(item, tokens, req)
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 ''
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
return f'<input type="hidden" name="{name}" value="{e(value)}">'
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
cb_label = item.get('checkbox_label')
if cb_label:
return (f'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<label class="form-checkbox-row">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox">'
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
f'</label>{hint_html}</div>')
return (f'<div class="form-group">'
f'<label class="form-label">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"> {label}'
f'</label>{hint_html}</div>')
if input_type == 'checkbox_group':
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
selected = json.loads(value) if value else []
except Exception:
opts, selected = [], []
boxes = ''.join(
f'<label class="checkbox-group-item">'
f'<input type="checkbox" name="{name}" value="{e(o.get("value",""))}"'
f'{"checked" if o.get("value") in selected else ""}> {e(o.get("label",""))}'
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>'
f'<select name="{name}" class="form-select{extra_cls}">{opts_html}</select>'
f'{hint_html}</div>')
if input_type == 'number':
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr} class="form-input{extra_cls}"{readonly}>'
f'{hint_html}</div>')
if input_type == 'textarea':
rows = item.get('rows', 4)
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<textarea name="{name}" rows="{rows}" placeholder="{placeholder}"'
f' class="form-input">{e(value)}</textarea>'
f'{hint_html}</div>')
if input_type == 'interface_picker':
current = apply_tokens(item.get('value', ''), tokens)
try:
ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens))
except Exception:
ifaces = []
state_map = {
'UP': ('badge-enabled', 'Up'),
'DOWN': ('badge-warning', 'Down'),
'INVALID': ('badge-danger', 'Invalid'),
}
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 '&nbsp;' * 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 = '&nbsp;' * 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>{"&nbsp;" * 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">'
f'<input type="hidden" name="{name}" value="{e(current)}">'
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 ''
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if (item.get('readonly') or item.get('dyn_hint') or validate) else ''
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}{validate_attr}>{hint_html}{dyn_hint}</div>')
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
def _render_editable_list(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
ph = e(apply_tokens(item.get('item_placeholder', ''), tokens))
add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
validate = e(item.get('validate', ''))
try:
items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens))
except Exception:
items_list = []
rows = ''.join(
f'<div class="editable-list-item">'
f'<input type="text" name="{name}" value="{e(v)}" class="form-input">'
f'<button type="button" class="btn btn-ghost btn-sm editable-list-remove">Remove</button>'
f'</div>'
for v in items_list
)
validate_attr = f' data-validate="{validate}"' if validate else ''
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}"{validate_attr}>'
f'{rows}'
f'<button type="button" class="btn btn-ghost btn-sm editable-list-add">+ {add_lbl}</button>'
f'</div>{hint_html}</div>')
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', [])
hash_val = core_hash()
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>'
thead = ''.join(
f'<th class="{e(c["class"])}">{e(c.get("label",""))}</th>' if c.get("class") else f'<th>{e(c.get("label",""))}</th>'
for c in columns
)
if row_actions:
thead += '<th></th>'
if not rows:
colspan = len(columns) + (1 if row_actions else 0)
tbody = f'<tr><td colspan="{colspan}" class="table-empty">{empty}</td></tr>'
else:
tbody = ''
for idx, row in enumerate(rows):
cells = ''
for col in columns:
val = row
for part in col.get('field', '').split('.'):
val = val.get(part, '') if isinstance(val, dict) else ''
col_req = col.get('client_requirement', inherited_req)
toggle_allowed = _passes(col_req, level) if col_req else True
cells += _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':
disable_if = ra.get('disable_if')
if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'):
btns += f'<button type="button" class="btn {cls}" disabled>{text}</button>'
continue
btns += (f'<form method="post" action="{action}" style="display:inline">'
f'<input type="hidden" name="row_index" value="{idx}">'
f'<input type="hidden" name="config_hash" value="{e(hash_val)}">'
f'<button type="submit" class="btn {cls}">{text}</button></form>')
elif method == 'js_edit':
target = e(ra.get('target', 'edit-form'))
row_json = e(json.dumps(row))
btns += (f'<button type="button" class="btn {cls} row-edit-btn"'
f' data-row-index="{idx}" data-row="{row_json}"'
f' data-target="{target}">{text}</button>')
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>'
if render_fn == 'badge_toggle':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
label = 'Enabled'; badge_cls = 'badge-enabled'
else:
label = 'Disabled'; badge_cls = 'badge-disabled'
if toggle_action and row_idx is not None and toggle_allowed:
inner = (f'<form method="post" action="{e(toggle_action)}" style="display:inline">'
f'<input type="hidden" name="row_index" value="{row_idx}">'
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>'
if render_fn == 'tag_list':
try:
items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')]
except Exception:
items = [value]
def _tag(t):
if isinstance(t, dict):
s, tooltip = str(t.get('n', '')).strip(), str(t.get('d', t.get('n', ''))).strip()
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)
return f'{td_open}<div class="tag-list">{tags}</div></td>'
if render_fn == 'interface_status':
v = value.upper()
if v == 'INVALID':
inner = '<span class="badge badge-danger">Invalid</span>'
elif v == 'UP':
inner = '<span class="badge badge-enabled">Up</span>'
elif v == 'DOWN':
inner = '<span class="badge badge-warning">Down</span>'
else:
inner = f'<span class="badge badge-disabled">{e(value.title())}</span>'
return f'{td_open}{inner}</td>'
return f'{td_open}{e(value)}</td>'
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 []
# -- Layout renderer -----------------------------------------------------------
def render_layout(view_id, content_html, tokens):
css = _load_css()
level = _client_level()
titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{PRODUCT_DISPLAY_NAME}</span></div>'
navbar_html = _render_navbar(view_id, level, tokens)
footer_html = f'<footer class="footer">{PRODUCT_DISPLAY_NAME}</footer>'
page_hash = core_hash()
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', '[]')
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)
text = f'{e(o_user)} has pending changes which will be applied {timing}.' if timing else f'{e(o_user)} has pending changes which will be applied on the next timer tick.'
cls = 'info-bar-warning'
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}">{text}</div>\n'
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f' <meta charset="UTF-8">\n'
f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
f' <title>{PRODUCT_DISPLAY_NAME}</title>\n'
f' <style>{css}</style>\n'
f'</head>\n<body>\n'
f'{titlebar_html}\n'
f'{navbar_html}\n'
f'<main class="main-content">\n{other_bars}{content_html}\n</main>\n'
f'{footer_html}\n'
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'
f'<script>{_inline_js()}</script>\n'
f'</body>\n</html>')
def _render_navbar(active_view, level, tokens):
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
frag = _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req)
(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>')
def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None):
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 ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}'
if action:
return (f'<form method="post" action="/action/{e(action)}" style="display:inline">'
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
children += _render_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req)
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 ''
# -- Inline JavaScript ---------------------------------------------------------
def _inline_js():
return r"""
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;
}
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;
}
function classifyMac(s) {
if (!s) return 'empty';
if (/[^0-9a-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-9a-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-9a-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';
}
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');
var hintContainer = fg || input.parentElement;
if (hintContainer) {
var hint = hintContainer.querySelector('.field-dyn-hint');
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 {
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';
} 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) {
if (id === 0 && !isVpn) {
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);
});
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]) : '';
}
});
target.style.display = '';
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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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||'') +
'" class="form-input inline-edit-input"></div>' +
'<div class="cred-field"><span class="cred-label">P:</span>' +
'<input type="password" name="password" value="' + esc(data.password||'') +
'" class="form-input inline-edit-input"></div>';
} else {
return '<input type="text" name="api_token" value="' + esc(data.api_token||'') +
'" class="form-input inline-edit-input" placeholder="API Token">';
}
}
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');
td.innerHTML = '<input type="checkbox" name="' + field + '"' +
(checked ? ' checked' : '') + ' class="inline-edit-checkbox">';
} 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) + '"' +
(isChecked ? ' checked' : '') + ' class="inline-edit-checkbox-multi"> ' + esc(o.label) + '</label>';
});
cbHtml += '</div>';
td.innerHTML = cbHtml;
} 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;
} 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)) +
'"' + minAttr + maxAttr + ' class="form-input inline-edit-input">';
} 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 {
var validateAttr = fDef.validate ? ' data-validate="' + esc(fDef.validate) + '"' : '';
var hintHtml = fDef.validate ? '<p class="form-hint field-dyn-hint" style="display:none"></p>' : '';
td.innerHTML = '<input type="' + inputType + '" name="' + field +
'" value="' + esc(String(val)) + '" class="form-input inline-edit-input"' + validateAttr + '>' + hintHtml;
if (fDef.validate && typeof validateEl === 'function') validateEl(td.querySelector('input'));
}
});
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') {
if (inp.classList.contains('inline-edit-checkbox-multi')) {
if (inp.checked) addHidden(inp.name, inp.value);
} else {
if (inp.checked) addHidden(inp.name, 'on');
}
} 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;
});
}
});
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);
});
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';
});
});
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;
(function() {
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' },
ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 032',
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',
invalid_struct: 'No leading, trailing or consecutive special characters' }
};
var _classifiers = { ip: classifyIp, ipv4: classifyIpv4, ipv6: classifyIpv6, mac: classifyMac,
subnet: classifySubnet, url: classifyUrl,
port: classifyPort, ipv4cidr: classifyIpv4Cidr,
endpoint: classifyEndpoint,
dashname: classifyDashname, domainname: classifyDomainname, networkname: classifyNetworkname };
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;
var cls = classify(el.value);
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 {
setFieldHint(el, msgs[cls] || 'Invalid', 'error');
}
}
};
// Regular fields (not inside editable lists) — initial state + expose _triggerValidate
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);
});
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);
});
});
}).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; });
// Only track fields named in original — naturally excludes config_hash,
// 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; });
}
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) : '';
if (el.type === 'checkbox') {
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}));
}
});
checkDirty();
form.querySelectorAll('input[data-validate]').forEach(function(el) {
if (typeof validateEl === 'function') validateEl(el);
});
}
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; });
};
});
})();
(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); });
});
})();
function startApplyPoller(uuid, bar, mine) {
var nextIn = null;
var pollTimer = null;
var tickTimer = null;
function user() { return bar.getAttribute('data-apply-user') || ''; }
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function setHtml(html) { bar.innerHTML = html; }
function updateCountdown() {
if (nextIn === null) {
setHtml(mine ? 'Configuration saved. The command processing service is not installed. Run <strong>core.py --install</strong> to enable it, or <strong>core.py --apply</strong> to apply manually.'
: esc(user()) + ' has pending changes. The command processing service is not installed.');
return;
}
var timing = nextIn <= 5 ? 'momentarily'
: nextIn < 60 ? 'in about ' + nextIn + ' seconds'
: 'in about ' + Math.round(nextIn / 60) + ' minute' + (Math.round(nextIn / 60) !== 1 ? 's' : '');
setHtml(mine ? 'Configuration saved. Changes will be applied ' + timing + '.'
: esc(user()) + ' has pending changes which will be applied ' + timing + '.');
}
function onStatus(data) {
if (data.status === 'complete') {
bar.classList.remove('info-bar-running');
setHtml(mine ? 'Changes have been applied.' : esc(user()) + '\'s changes have been applied.');
clearTimeout(pollTimer);
clearTimeout(tickTimer);
return;
}
if (data.status === 'running') {
bar.classList.add('info-bar-running');
setHtml(mine ? 'Configuration saved. Changes are being applied now...'
: esc(user()) + '\'s changes are being applied now...');
} else {
bar.classList.remove('info-bar-running');
if (data.next_in !== null && data.next_in !== undefined) { nextIn = data.next_in; }
updateCountdown();
}
pollTimer = setTimeout(doPoll, 3000);
}
function doPoll() {
fetch('/api/apply-status?uuid=' + encodeURIComponent(uuid))
.then(function(r) { return r.json(); })
.then(onStatus)
.catch(function() { pollTimer = setTimeout(doPoll, 3000); });
}
function tick() {
if (nextIn !== null && nextIn > 0) { nextIn--; updateCountdown(); }
tickTimer = setTimeout(tick, 1000);
}
doPoll();
tick();
}
(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);
});
})();
(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();
card.style.display = '';
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();
})();
"""
# -- Routes --------------------------------------------------------------------
@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)
flash_html += f'<div class="info-bar info-bar-{variant} info-bar-flash">{msg_html}</div>'
content_html = flash_html + render_items(view_def.get('items', []), tokens, view_req)
return render_layout(view_id, content_html, tokens)