2510 lines
104 KiB
Python
2510 lines
104 KiB
Python
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 zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
from config_utils import core_hash, get_pending_entries, get_dashboard_pending, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, PRODUCT_DISPLAY_NAME
|
|
|
|
bp = Blueprint('view_page', __name__)
|
|
|
|
DATA_DIR = '/data'
|
|
CONFIGS_DIR = '/routlin_location'
|
|
|
|
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_core().get('ddns', {})
|
|
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 _relative_time(ts):
|
|
try:
|
|
diff = int(datetime.now(tz=timezone.utc).timestamp()) - int(ts)
|
|
if diff < 60:
|
|
n = max(0, diff)
|
|
return f'{n} second{"s" if n != 1 else ""} ago'
|
|
m = diff // 60
|
|
if m < 60:
|
|
return f'{m} minute{"s" if m != 1 else ""} ago'
|
|
h = m // 60
|
|
if h < 24:
|
|
return f'{h} hour{"s" if h != 1 else ""} ago'
|
|
d = h // 24
|
|
if d < 365:
|
|
return f'{d} day{"s" if d != 1 else ""} ago'
|
|
y = d // 365
|
|
return f'{y} year{"s" if y != 1 else ""} ago'
|
|
except Exception:
|
|
return ''
|
|
|
|
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 _blocklist_stats_html(core):
|
|
bl_dir = f'{CONFIGS_DIR}/blocklists'
|
|
rows = ''
|
|
for bl in core.get('blocklists', []):
|
|
name = e(bl.get('name', ''))
|
|
save_as = bl.get('save_as', '')
|
|
bl_path = f'{bl_dir}/{save_as}' if save_as else ''
|
|
try:
|
|
with open(bl_path) as f:
|
|
entries = sum(1 for _ in f)
|
|
mtime = int(os.path.getmtime(bl_path))
|
|
size_str = _fmt_bytes(os.path.getsize(bl_path))
|
|
tz_name = session.get('timezone', '')
|
|
try:
|
|
tz = ZoneInfo(tz_name) if tz_name else timezone.utc
|
|
except ZoneInfoNotFoundError:
|
|
tz = timezone.utc
|
|
last_refreshed = f'{datetime.fromtimestamp(mtime, tz=tz).strftime("%Y-%m-%d %H:%M")} ({_relative_time(mtime)})'
|
|
except Exception:
|
|
entries, size_str, last_refreshed = '-', '-', 'Never'
|
|
rows += (f'<tr>'
|
|
f'<td class="table-cell">{name}</td>'
|
|
f'<td class="table-cell">{entries}</td>'
|
|
f'<td class="table-cell">{size_str}</td>'
|
|
f'<td class="table-cell">{e(last_refreshed)}</td>'
|
|
f'</tr>')
|
|
if not rows:
|
|
return ''
|
|
return (
|
|
'<table class="data-table" style="margin-bottom:1rem">'
|
|
'<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>'
|
|
)
|
|
|
|
|
|
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', '-'))
|
|
tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if gen.get('apply_on_save', True) else 'false'
|
|
|
|
pending_items = get_dashboard_pending()
|
|
if pending_items:
|
|
rows = ''
|
|
for _uuid, ts, cmd, user, desc in pending_items:
|
|
dt_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
|
|
label = e(desc) if desc else e(cmd)
|
|
rows += (f'<tr><td class="table-cell">{e(dt_str)}</td>'
|
|
f'<td class="table-cell">{label}</td>'
|
|
f'<td class="table-cell">{e(user)}</td></tr>')
|
|
pending_html = (
|
|
'<hr class="divider">'
|
|
'<h3 style="margin:0 0 0.75rem 0;font-size:0.85rem;font-weight:600;'
|
|
'text-transform:uppercase;letter-spacing:0.05em;color:var(--text-muted)">Pending Changes</h3>'
|
|
'<table class="data-table" style="margin-bottom:1rem">'
|
|
'<thead><tr>'
|
|
'<th class="table-header">Time</th>'
|
|
'<th class="table-header">Change</th>'
|
|
'<th class="table-header">User</th>'
|
|
'</tr></thead>'
|
|
f'<tbody>{rows}</tbody>'
|
|
'</table>'
|
|
'<form method="post" action="/action/general_cardpendingchanges_applyselected">'
|
|
f'<input type="hidden" name="config_hash" value="{e(core_hash())}">'
|
|
'<div class="button-row">'
|
|
'<button type="submit" class="btn btn-primary">Apply Now</button>'
|
|
'</div></form>'
|
|
)
|
|
else:
|
|
pending_html = ''
|
|
tokens['PENDING_CHANGES_HTML'] = pending_html
|
|
|
|
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', [])))
|
|
tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(core)
|
|
|
|
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)
|
|
|
|
if t == 'raw_html':
|
|
return Markup(apply_tokens(item.get('html', ''), tokens))
|
|
|
|
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 ' ' * max(0, speed_pad - len(s)) + e(s)
|
|
for ifc in ifaces:
|
|
iname = ifc.get('name', '')
|
|
wireless = ifc.get('wireless', False)
|
|
state = ifc.get('state', 'UNKNOWN')
|
|
carrier = ifc.get('carrier')
|
|
raw_speed = ifc.get('speed')
|
|
raw_mtu = ifc.get('mtu')
|
|
raw_mac = ifc.get('mac')
|
|
speed = raw_speed or '-'
|
|
mtu = raw_mtu or '-'
|
|
mac = raw_mac or '-'
|
|
sc, st = state_map.get(state, ('badge-disabled', state.title()))
|
|
type_txt = 'Wireless' if wireless else 'Wired'
|
|
if wireless:
|
|
carrier_txt = '-'
|
|
else:
|
|
carrier_txt = 'Yes' if carrier else ('No' if carrier is False else '-')
|
|
sel_cls = ' selected' if iname == current else ''
|
|
if iname == current:
|
|
cur_sc, cur_st = sc, st
|
|
cur_speed, cur_mtu, cur_mac = raw_speed, raw_mtu, raw_mac
|
|
cur_perm_mac = ifc.get('perm_mac')
|
|
cur_min_mtu = ifc.get('min_mtu')
|
|
cur_max_mtu = ifc.get('max_mtu')
|
|
padded_speed = _pad_speed(raw_speed)
|
|
padded_mtu = ' ' * max(0, 4 - len(raw_mtu or '-')) + e(raw_mtu or '-')
|
|
rows_html += (f'<tr class="iface-picker-row{sel_cls}" data-iface="{e(iname)}"'
|
|
f' data-state-class="{e(sc)}" data-state-label="{e(st)}"'
|
|
f' data-speed="{padded_speed}" data-mtu="{padded_mtu}"'
|
|
f' data-mac="{e(raw_mac or "")}">'
|
|
f'<td class="col-mono">{e(iname)}</td>'
|
|
f'<td>{e(type_txt)}</td>'
|
|
f'<td><span class="badge {sc}">{st}</span></td>'
|
|
f'<td>{e(carrier_txt)}</td>'
|
|
f'<td>{e(speed)}</td>'
|
|
f'<td>{e(mtu)}</td>'
|
|
f'<td class="col-mono">{e(mac)}</td>'
|
|
f'</tr>')
|
|
table_html = (f'<div class="table-wrapper">'
|
|
f'<table class="data-table iface-picker-table">'
|
|
f'<thead><tr><th>Interface</th><th>Type</th><th>State</th>'
|
|
f'<th>Carrier</th><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead>'
|
|
f'<tbody>{rows_html}</tbody>'
|
|
f'</table></div>')
|
|
btn_label = f'<span class="iface-picker-name">{e(current) or "Select..."}</span>'
|
|
btn_badge = (f'<span class="badge {cur_sc} iface-picker-badge">{e(cur_st)}</span>'
|
|
if current else '')
|
|
if current and any([cur_speed, cur_mtu, cur_mac]):
|
|
ext_meta = (f'<table class="iface-picker-stats">'
|
|
f'<thead><tr><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead>'
|
|
f'<tbody><tr>'
|
|
f'<td>{_pad_speed(cur_speed)}</td>'
|
|
f'<td>{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")}</td>'
|
|
f'<td class="col-mono">{e(cur_mac or "-")}</td>'
|
|
f'</tr></tbody>'
|
|
f'</table>')
|
|
else:
|
|
ext_meta = ''
|
|
configure_btn = (
|
|
f'<button type="button" class="btn btn-secondary iface-configure-btn"'
|
|
f' data-iface="{e(current)}" data-mtu="{e(cur_mtu or "")}"'
|
|
f' data-mac="{e(cur_mac or "")}" data-perm-mac="{e(cur_perm_mac or "")}"'
|
|
f' data-min-mtu="{cur_min_mtu if cur_min_mtu is not None else ""}"'
|
|
f' data-max-mtu="{cur_max_mtu if cur_max_mtu is not None else ""}">'
|
|
f'Configure</button>'
|
|
) if current else ''
|
|
return (f'<div class="form-group">'
|
|
f'<label class="form-label">{label}</label>'
|
|
f'<div class="iface-picker">'
|
|
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'
|
|
|
|
problem_bars = ''
|
|
try:
|
|
import json as _j
|
|
st = _j.load(open(f'{CONFIGS_DIR}/.status'))
|
|
for section in ('configurations', 'logs'):
|
|
for item in st.get(section, []):
|
|
if item.get('status') == 'problem':
|
|
sev = item.get('severity', 'error')
|
|
cls = 'info-bar-danger' if sev == 'error' else 'info-bar-warning'
|
|
text = e(item.get('detail', item.get('name', '')))
|
|
tip = item.get('suggestion', '')
|
|
if tip:
|
|
text += f' <span style="opacity:0.75">- {e(tip)}</span>'
|
|
problem_bars += f'<div class="info-bar {cls}">{text}</div>\n'
|
|
except Exception:
|
|
pass
|
|
|
|
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{problem_bars}{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 showCard(el) {
|
|
el.style.display = '';
|
|
el.classList.remove('card-reveal');
|
|
void el.offsetWidth;
|
|
el.classList.add('card-reveal');
|
|
el.addEventListener('animationend', function() { el.classList.remove('card-reveal'); }, {once: true});
|
|
}
|
|
|
|
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 classifyTime24h(s) {
|
|
if (!s) return 'empty';
|
|
if (/[^0-9:]/.test(s)) return 'invalid_char';
|
|
if (s.length < 5) return 'incomplete';
|
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(s)) return 'invalid_struct';
|
|
return 'complete';
|
|
}
|
|
|
|
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]) : '';
|
|
}
|
|
});
|
|
showCard(target);
|
|
target.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', function(e) {
|
|
var btn = e.target.closest('.row-inline-edit-btn');
|
|
if (!btn) return;
|
|
var rowData = JSON.parse(btn.dataset.row);
|
|
var idx = btn.dataset.rowIndex;
|
|
var action = btn.dataset.action;
|
|
var fields = JSON.parse(btn.dataset.fields);
|
|
var tr = btn.closest('tr');
|
|
var fieldMap = {};
|
|
fields.forEach(function(f) { fieldMap[f.col] = f; });
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function buildCredentialsHtml(provider, data) {
|
|
if (provider === 'noip') {
|
|
return '<div class="cred-field"><span class="cred-label">U:</span>' +
|
|
'<input type="text" name="username" value="' + esc(data.username||'') +
|
|
'" 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 0-32',
|
|
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' },
|
|
time_24h: { invalid_char: 'Digits and colon only', invalid_struct: 'Must be HH:MM in 24-hour format (e.g. 02:30)' }
|
|
};
|
|
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,
|
|
time_24h: classifyTime24h };
|
|
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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();
|
|
showCard(card);
|
|
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)
|