linuxrouter/docker/router-dash/app/view_page.py

1640 lines
66 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 validate
from datetime import datetime, timezone
from config_utils import core_hash
bp = Blueprint('view_page', __name__)
DATA_DIR = '/data'
CONFIGS_DIR = '/configs'
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
# -- Access level --------------------------------------------------------------
def _client_level():
return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
def _passes(req, level):
if not req:
return False
for suffix, check in (('+', lambda n, l: l >= n),
('-', lambda n, l: l <= n),
('=', lambda n, l: l == n)):
if req.endswith(suffix):
role = req[:-1].replace('client_is_', '', 1)
needed = LEVEL_RANK.get(role)
if needed is None:
print(f'[view_page] WARNING: unknown role "{role}" in client_requirement "{req}"', file=sys.stderr)
return False
return check(needed, level)
print(f'[view_page] WARNING: client_requirement "{req}" has no valid suffix (+, -, =)', file=sys.stderr)
return False
# -- File loaders --------------------------------------------------------------
def _load_json(path):
try:
with open(path) as f:
return json.load(f)
except Exception as ex:
print(f'[view_page] ERROR loading {path}: {ex}', file=sys.stderr)
return {}
def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json')
def _load_ddns(): return _load_json(f'{CONFIGS_DIR}/ddns.json')
def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
def _load_css():
try:
with open(f'{DATA_DIR}/page_styles.css') as f:
return f.read()
except Exception:
return ''
# -- Shell helper --------------------------------------------------------------
def _run(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
def _prefix_to_dotted(n):
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
def _get_system_interfaces():
"""Return sorted list of physical-ish interface names from `ip link show`."""
out = _run('ip link show')
names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE)
ifaces = sorted({n.split('@')[0] for n in names} - {'lo'})
return ifaces
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 + vlan_id + general.lan_interface."""
if vlan.get('is_vpn'):
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
return f'wg{idx}'
lan = core.get('general', {}).get('lan_interface', 'eth0')
vid = vlan.get('vlan_id', 1)
return lan if vid == 1 else f'{lan}.{vid}'
# -- Live data loaders ---------------------------------------------------------
def _live_dhcp_leases():
rows = []
leases_file = '/var/lib/misc/dnsmasq.leases'
try:
with open(leases_file) as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 4:
rows.append({
'hostname': parts[3] if parts[3] != '*' else '-',
'ip_address': parts[2],
'mac_address': parts[1],
'vlan_name': _vlan_name_for_ip(parts[2]),
'expires': _fmt_timestamp(int(parts[0])),
})
except Exception:
pass
return rows
def _vlan_name_for_ip(ip):
import ipaddress
for vlan in _load_core().get('vlans', []):
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', 24)
if not subnet:
continue
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False):
return vlan.get('name', '-')
except Exception:
pass
return '-'
def _fmt_timestamp(ts):
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
except Exception:
return '-'
def _live_vpn_sessions():
rows = []
out = _run('wg show all dump 2>/dev/null')
for line in out.splitlines():
parts = line.split('\t')
if len(parts) == 9:
interface, _pubkey, _psk, endpoint, allowed_ips, last_hs, rx, tx, _ka = parts
rows.append({
'peer_name': _pubkey[:16] + '...',
'interface': interface,
'tunnel_ip': allowed_ips.split(',')[0].split('/')[0] if allowed_ips else '-',
'endpoint': endpoint if endpoint != '(none)' else '-',
'last_handshake': _fmt_timestamp(int(last_hs)) if last_hs.isdigit() and last_hs != '0' else 'Never',
'rx_bytes': _fmt_bytes(int(rx)) if rx.isdigit() else '-',
'tx_bytes': _fmt_bytes(int(tx)) if tx.isdigit() else '-',
})
return rows
def _fmt_bytes(n):
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f'{n:.1f} {unit}'
n /= 1024
return f'{n:.1f} TB'
# -- Config data loaders -------------------------------------------------------
def _config_datasource(name):
core = _load_core()
vlans = core.get('vlans', [])
if name == 'interfaces':
gen = core.get('general', {})
wan = gen.get('wan_interface', '')
lan = gen.get('lan_interface', '')
return [
{'iface_type': 'WAN', 'interface': wan, 'status': _iface_status(wan)},
{'iface_type': 'LAN', 'interface': lan, 'status': _iface_status(lan)},
]
if name == 'banned_ips':
return core.get('banned_ips', [])
if name == 'host_overrides':
return core.get('host_overrides', [])
if name == 'blocklists':
rows = []
for bl in core.get('blocklists', []):
row = dict(bl)
bl_path = f'{CONFIGS_DIR}/blocklists/{bl.get("save_as", "")}'
try:
with open(bl_path) as f:
row['domain_count'] = str(sum(1 for _ in f))
row['last_updated'] = _fmt_timestamp(int(os.path.getmtime(bl_path)))
except Exception:
row['domain_count'] = '-'
row['last_updated'] = '-'
rows.append(row)
return rows
if name == 'vlans':
bl_desc = {b['name']: b.get('description', b['name']) for b in core.get('blocklists', []) if 'name' in b}
rows = []
for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)):
row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
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'):
row['credentials'] = '(token set)' if p.get('api_token') 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':
wg_vlan = next((v for v in vlans if v.get('is_vpn')), None)
if not wg_vlan:
return []
rows = []
for peer in wg_vlan.get('peers', []):
row = dict(peer)
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
rows.append(row)
return rows
return []
# -- Live stat helpers ---------------------------------------------------------
def _get_dnsmasq_stats():
stats = {'queries': '-', 'hits': '-', 'hit_rate': '-',
'forwarded': '-', 'auth': '-', 'tcp_peak': '-'}
out = _run('journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null')
for line in reversed(out.splitlines()):
if 'queries forwarded' in line:
m = re.search(r'queries forwarded (\d+)', line)
if m: stats['forwarded'] = m.group(1)
m = re.search(r'queries answered locally (\d+)', line)
if m: stats['hits'] = m.group(1)
fwd = int(stats['forwarded']) if stats['forwarded'] != '-' else 0
hit = int(stats['hits']) if stats['hits'] != '-' else 0
total = fwd + hit
stats['queries'] = str(total) if total else '-'
if total > 0:
stats['hit_rate'] = f'{hit / total * 100:.0f}%'
break
if 'auth answered' in line:
m = re.search(r'auth answered (\d+)', line)
if m and stats['auth'] == '-':
stats['auth'] = m.group(1)
if 'max TCP connections' in line:
m = re.search(r'max TCP connections (\d+)', line)
if m and stats['tcp_peak'] == '-':
stats['tcp_peak'] = m.group(1)
return stats
def _count_blocked_today():
out = _run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
return out or '0'
def _count_blocked_domains():
bl_dir = f'{CONFIGS_DIR}/blocklists'
try:
total = sum(
int(_run(f'wc -l < "{bl_dir}/{f}"') or 0)
for f in os.listdir(bl_dir) if f.endswith('.conf')
)
return str(total)
except Exception:
return '-'
def _bl_last_update():
bl_dir = f'{CONFIGS_DIR}/blocklists'
try:
mtime = max(
os.path.getmtime(f'{bl_dir}/{f}')
for f in os.listdir(bl_dir) if f.endswith('.conf')
)
return _fmt_timestamp(int(mtime))
except Exception:
return '-'
def _ddns_log_tail(n=50):
log_path = f'{CONFIGS_DIR}/ddns.log'
try:
with open(log_path) as f:
lines = f.readlines()
return ''.join(lines[-n:]).strip() or '(log is empty)'
except FileNotFoundError:
return '(log file not found)'
except Exception:
return '(error reading log)'
def _fmt_seconds(secs):
secs = int(secs)
if secs < 60:
return f'{secs}s'
m, s = divmod(secs, 60)
if m < 60:
return f'{m}m {s}s' if s else f'{m}m'
h, m = divmod(m, 60)
return f'{h}h {m}m' if m else f'{h}h'
def _parse_interval_to_seconds(s):
m = re.match(r'^(\d+)([mhd])$', str(s).strip())
if not m:
return None
val, unit = int(m.group(1)), m.group(2)
return val * {'m': 60, 'h': 3600, 'd': 86400}[unit]
def _parse_time_remaining(text):
for line in text.splitlines():
if 'Trigger:' in line:
total, found = 0, False
for amt, unit in re.findall(r'(\d+)\s*(day|h|min|s)\b', line):
total += int(amt) * {'day': 86400, 'h': 3600, 'min': 60, 's': 1}[unit]
found = True
if found:
return total
return None
def _read_cached_ip():
try:
best_ip, best_mtime = '', 0
for fname in os.listdir(CONFIGS_DIR):
if fname.startswith('.ddns-last-ip-'):
path = f'{CONFIGS_DIR}/{fname}'
mtime = os.path.getmtime(path)
if mtime > best_mtime:
ip = open(path).read().strip()
if ip:
best_ip, best_mtime = ip, mtime
return best_ip
except Exception:
return ''
def _public_ip_info(ddns_cfg):
"""Return (ip_str, domains_sub, next_interval_str) for stat cards."""
script = f'{CONFIGS_DIR}/ddns.py'
enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
all_hosts = []
for p in enabled_p:
all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
domains_sub = ', '.join(all_hosts)
interval_secs = _parse_interval_to_seconds(ddns_cfg.get('general', {}).get('timer_interval', ''))
next_interval = '-'
# Path 1: timer healthy and within interval -> use cached IP
if interval_secs and enabled_p:
status = _run(f'python3 {script} --status 2>/dev/null')
if status:
is_enabled = '; enabled' in status
is_active = 'active (waiting)' in status or 'active (running)' in status
remaining = _parse_time_remaining(status)
if remaining is not None:
next_interval = _fmt_seconds(remaining)
if is_enabled and is_active and remaining is not None and remaining < interval_secs:
ip = _read_cached_ip()
if ip:
return ip, domains_sub, next_interval
# Path 2: live fetch
ip = _run(f'python3 {script} --getip 2>/dev/null')
if ip and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
return ip, domains_sub, next_interval
# Path 3: offline
return 'DDNS Offline', domains_sub, next_interval
def _vpn_info():
for vlan in _load_core().get('vlans', []):
if 'vpn_information' in vlan:
return vlan['vpn_information']
return {}
# -- Token collection ----------------------------------------------------------
def collect_tokens():
tokens = {}
core = _load_core()
gen = core.get('general', {})
dns = core.get('upstream_dns', {})
vlans = core.get('vlans', [])
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
tokens['GENERAL_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]
)
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
servers = dns.get('upstream_servers', [])
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-'
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
vlan_names = [v.get('name', '') for v in vlans]
tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
tokens['STAT_LEASE_COUNT'] = str(len(_live_dhcp_leases()))
filter_opts = '<option value="all">All VLANs</option>' + ''.join(
f'<option value="{e(n)}">{e(n)}</option>' for n in vlan_names
)
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans])
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(len(core.get('blocklists', [])))
ddns = _load_ddns()
tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
_ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'}
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
{'value': p, 'label': _ddns_labels.get(p, p.title())}
for p in validate.VALID_DDNS_PROVIDERS
])
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
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 == '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())}">'
return f'<form action="{action}" method="{method}">{hash_field}{inner}</form>'
if t == 'field':
return _render_field(item, tokens)
if t == 'field_row':
inner = render_items(item.get('items', []), tokens, req)
cols = item.get('cols', 2)
return f'<div class="form-row-{cols}">{inner}</div>'
if t == 'subnet_row':
subnet_name = e(item.get('subnet_name', 'subnet'))
prefix_name = e(item.get('prefix_name', 'subnet_mask'))
subnet_val = apply_tokens(item.get('subnet_value', ''), tokens)
prefix_raw = apply_tokens(item.get('prefix_value', '24'), tokens)
subnet_ph = e(apply_tokens(item.get('subnet_placeholder', ''), tokens))
show_derived = item.get('show_derived_vlan_id', False)
try:
pf = max(1, min(30, int(prefix_raw)))
except (ValueError, TypeError):
pf = 24
dotted = _prefix_to_dotted(pf)
return (
f'<div class="form-group">'
f'<label class="form-label">Subnet</label>'
f'<div class="subnet-row-wrap">'
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input">'
f'<span class="subnet-sep">/</span>'
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input">'
f'<span class="subnet-dotted">{e(dotted)}</span>'
f'</div>'
f'<p class="form-hint field-dyn-hint" style="display:none"></p>'
f'</div>'
)
if t == 'editable_list':
return _render_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
filter_col = item.get('filter_col', '')
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
if t == 'button_row':
inner = render_items(item.get('items', []), tokens, req)
return f'<div class="button-row">{inner}</div>'
if t == 'table':
return _render_table(item, tokens, req)
return ''
def _render_field(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
input_type = item.get('input_type', 'text')
value = apply_tokens(item.get('value', ''), tokens)
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
return f'<input type="hidden" name="{name}" value="{e(value)}">'
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
cb_label = item.get('checkbox_label')
if cb_label:
return (f'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<label class="form-checkbox-row">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox">'
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
f'</label>{hint_html}</div>')
return (f'<div class="form-group">'
f'<label class="form-label">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"> {label}'
f'</label>{hint_html}</div>')
if input_type == 'checkbox_group':
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
selected = json.loads(value) if value else []
except Exception:
opts, selected = [], []
boxes = ''.join(
f'<label class="checkbox-group-item">'
f'<input type="checkbox" name="{name}" value="{e(o.get("value",""))}"'
f'{"checked" if o.get("value") in selected else ""}> {e(o.get("label",""))}'
f'</label>'
for o in opts
)
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="checkbox-group">{boxes}</div>{hint_html}</div>')
if input_type == 'select':
options = item.get('options', [])
if isinstance(options, str):
try:
options = json.loads(apply_tokens(options, tokens))
except Exception:
options = []
current = apply_tokens(item.get('value', ''), tokens)
opts_html = ''.join(
f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}>{e(o["label"])}</option>'
for o in options
)
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select">{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>')
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if item.get('readonly') 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}>{hint_html}{dyn_hint}</div>')
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 ''
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
)
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}">'
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>{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 = '<div class="titlebar"><span class="titlebar-brand">Router Dashboard</span></div>'
navbar_html = _render_navbar(view_id, level, tokens)
footer_html = '<footer class="footer">Router Dashboard</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', '[]')
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>Router Dashboard</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{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};</script>\n'
f'<script>{_inline_js()}</script>\n'
f'</body>\n</html>')
def _render_navbar(active_view, level, tokens):
navbar_data = _load_json(f'{DATA_DIR}/navbar_content.json')
left, right = [], []
for item in navbar_data.get('items', []):
req = item.get('client_requirement')
align = item.get('align', 'left')
if not _passes(req, level):
continue
frag = _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req)
(right if align == 'right' else left).append(frag)
return (f'<nav class="nav-bar">'
f'<div class="nav-left">{"".join(left)}</div>'
f'<div class="nav-right">{"".join(right)}</div>'
f'</nav>')
def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None):
req = item.get('client_requirement', inherited_req)
t = item.get('type', '')
if t in ('nav_item', 'nav_action'):
label = e(item.get('label', ''))
map_to = item.get('map_to', '')
action = item.get('action', '')
is_active = ' active' if map_to and map_to == active_view else ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}'
if action:
return (f'<form method="post" action="/action/{e(action)}" style="display:inline">'
f'<button type="submit" class="{cls}">{label}</button></form>')
if map_to:
return f'<a href="/view/{e(map_to)}" class="{cls}">{label}</a>'
return f'<span class="{cls}">{label}</span>'
if t == 'nav_menu':
raw_label = item.get('label', '')
if raw_label == '%MENU_LABEL%':
raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View'
label = e(raw_label)
children = ''
for child in item.get('items', []):
child_req = child.get('client_requirement', req)
if not _passes(child_req, level):
continue
children += _render_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req)
if not children:
return ''
return (f'<div class="nav-menu">'
f'<button class="nav-item nav-menu-trigger" aria-haspopup="true">{label}</button>'
f'<div class="nav-dropdown">{children}</div>'
f'</div>')
return ''
# -- Inline JavaScript ---------------------------------------------------------
def _inline_js():
return r"""
function prefixToDotted(n) {
if (n < 1 || n > 30) return '';
var mask = ((0xFFFFFFFF << (32 - n)) >>> 0);
return [(mask >>> 24) & 0xFF, (mask >>> 16) & 0xFF, (mask >>> 8) & 0xFF, mask & 0xFF].join('.');
}
function deriveVlanId(subnet, prefix) {
var parts = subnet.split('.');
if (parts.length !== 4) return null;
var octets = parts.map(function(p) { return parseInt(p, 10); });
if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null;
var byteIdx = Math.floor((prefix - 1) / 8);
var id = octets[byteIdx];
return (id >= 0 && id <= 4094) ? id : null;
}
function networkBitsMessage(octets, prefix) {
var byteIdx = Math.floor((prefix - 1) / 8);
var hostBitsInActive = (prefix % 8 === 0) ? 0 : (8 - (prefix % 8));
var activeMask = hostBitsInActive === 0 ? 0xFF : ((0xFF << hostBitsInActive) & 0xFF);
var ordinals = ['1st', '2nd', '3rd', '4th'];
var parts = [];
if (hostBitsInActive > 0 && (octets[byteIdx] & ~activeMask) !== 0) {
var step = 1 << hostBitsInActive;
var vals = [];
for (var v = 0; v < 256; v += step) vals.push(String(v));
var valStr = vals.length <= 8
? vals.slice(0, -1).join(', ') + ' or ' + vals[vals.length - 1]
: 'a multiple of ' + step;
parts.push(ordinals[byteIdx] + ' quartet must be ' + valStr);
}
var badTrailing = [];
for (var i = byteIdx + 1; i < 4; i++) {
if (octets[i] !== 0) badTrailing.push(ordinals[i]);
}
if (badTrailing.length > 0) {
var nameStr = badTrailing.length === 1
? badTrailing[0]
: badTrailing.slice(0, -1).join(', ') + ' and ' + badTrailing[badTrailing.length - 1];
parts.push(nameStr + ' quartet' + (badTrailing.length > 1 ? 's' : '') + ' must be 0');
}
if (parts.length === 0) return null;
return parts.join('; ') + ' for /' + prefix;
}
function 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');
if (fg) {
var hint = fg.querySelector('.field-dyn-hint');
if (hint) {
hint.textContent = message;
hint.style.display = message ? '' : 'none';
hint.style.color = (state === 'error') ? 'var(--danger)' : 'var(--text-muted)';
}
}
input.classList.remove('field-invalid', 'field-warning');
if (state === 'error' && message) input.classList.add('field-invalid');
else if (state === 'warning') input.classList.add('field-warning');
}
function updateAddVlanForm(form) {
var nameInp = form.querySelector('input[name="name"]');
var subnetInp = form.querySelector('input[name="subnet"]');
var prefixInp = form.querySelector('input.subnet-prefix-input');
var vpnChk = form.querySelector('input[name="is_vpn"]');
var ifacePrev = form.querySelector('.vlan-iface-preview');
var derivedPrev = form.querySelector('.vlan-derived-id-preview');
var submitBtn = form.querySelector('.add-vlan-btn');
if (!subnetInp || !prefixInp) return;
var subnet = subnetInp.value.trim();
var prefix = parseInt(prefixInp.value, 10);
var isVpn = vpnChk && vpnChk.checked;
var lan = typeof LAN_IFACE !== 'undefined' ? LAN_IFACE : 'eth0';
var sClass = classifySubnet(subnet);
var id = (sClass === 'complete') ? deriveVlanId(subnet, prefix) : null;
// Derived VLAN ID preview
if (derivedPrev) derivedPrev.value = (id !== null) ? String(id) : '';
// Interface preview
var ifaceVal = '';
if (isVpn) {
ifaceVal = 'wg' + (typeof VPN_VLAN_COUNT !== 'undefined' ? VPN_VLAN_COUNT : 0);
} else if (id !== null) {
ifaceVal = (id === 1) ? lan : lan + '.' + id;
}
if (ifacePrev) ifacePrev.value = ifaceVal;
// Subnet sub-text + colour
var subnetMsg = '', subnetState = 'ok', subnetOk = false;
if (sClass === 'empty' || sClass === 'incomplete') {
subnetState = 'warning';
} else if (sClass === 'invalid_char' || sClass === 'invalid_struct' || sClass === 'too_many') {
subnetMsg = 'Invalid'; subnetState = 'error';
} else if (sClass === 'range') {
subnetMsg = 'Quartet out of range'; subnetState = 'error';
} else {
var octetsArr = subnet.split('.').map(Number);
var hostMsg = networkBitsMessage(octetsArr, prefix);
if (hostMsg) {
subnetMsg = hostMsg; subnetState = 'error';
} else if (id === 0) {
subnetMsg = 'Would compute to VLAN ID 0 (reserved)'; subnetState = 'error';
} else if (id === null || EXISTING_VLAN_IDS.indexOf(id) !== -1) {
subnetMsg = id === null ? '' : 'Duplicate'; subnetState = id === null ? 'warning' : 'error';
} else {
subnetOk = true;
}
}
setFieldHint(subnetInp, subnetMsg, subnetState);
// Interface duplicate/reserved sub-text
if (ifacePrev) {
if (id === 0 && !isVpn) {
setFieldHint(ifacePrev, 'Reserved', 'error');
} else {
var ifaceDupe = ifaceVal.length > 0 && EXISTING_VLAN_INTERFACES.indexOf(ifaceVal) !== -1;
setFieldHint(ifacePrev, ifaceDupe ? 'Duplicate' : '', ifaceDupe ? 'error' : 'ok');
}
}
// VLAN ID duplicate/reserved sub-text
if (derivedPrev) {
if (id === 0) {
setFieldHint(derivedPrev, 'Reserved', 'error');
} else {
var derivedDupe = id !== null && EXISTING_VLAN_IDS.indexOf(id) !== -1;
setFieldHint(derivedPrev, derivedDupe ? 'Duplicate' : '', derivedDupe ? 'error' : 'ok');
}
}
// Name validation + colour
if (submitBtn) {
var name = nameInp ? nameInp.value.trim().toLowerCase() : '';
var nameValid = name.length > 0 && /^[a-z0-9-]+$/.test(name);
var nameDupe = nameValid && EXISTING_VLAN_NAMES.indexOf(name) !== -1;
var nameOk = nameValid && !nameDupe;
if (nameInp) {
nameInp.classList.remove('field-invalid', 'field-warning');
if (name.length === 0) nameInp.classList.add('field-warning');
else if (!nameOk) nameInp.classList.add('field-invalid');
}
submitBtn.disabled = !(nameOk && subnetOk);
}
}
document.addEventListener('input', function(e) {
var wrap = e.target.closest('.subnet-row-wrap');
if (wrap) {
var dotLabel = wrap.querySelector('.subnet-dotted');
if (dotLabel) {
var n = parseInt(wrap.querySelector('.subnet-prefix-input').value, 10);
dotLabel.textContent = (n >= 1 && n <= 30) ? prefixToDotted(n) : '';
}
}
var form = e.target.closest('form');
if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form);
});
document.addEventListener('change', function(e) {
if (e.target.name !== 'is_vpn') return;
var form = e.target.closest('form');
if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form);
});
document.querySelectorAll('.row-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = JSON.parse(this.dataset.row);
var idx = this.dataset.rowIndex;
var target = document.getElementById(this.dataset.target);
if (!target) return;
var idxField = target.querySelector('[name="row_index"]');
if (idxField) idxField.value = idx;
Object.keys(row).forEach(function(key) {
var field = target.querySelector('[name="' + key + '"]');
if (!field) return;
if (field.type === 'checkbox') {
field.checked = row[key] === true || row[key] === 'true' || row[key] === 1;
} else {
field.value = row[key] != null ? String(row[key]) : '';
}
});
target.style.display = '';
target.scrollIntoView({behavior: 'smooth', block: 'nearest'});
});
});
document.addEventListener('click', function(e) {
var btn = e.target.closest('.row-inline-edit-btn');
if (!btn) return;
var rowData = JSON.parse(btn.dataset.row);
var idx = btn.dataset.rowIndex;
var action = btn.dataset.action;
var fields = JSON.parse(btn.dataset.fields);
var tr = btn.closest('tr');
var fieldMap = {};
fields.forEach(function(f) { fieldMap[f.col] = f; });
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function buildCredentialsHtml(provider, data) {
if (provider === 'noip') {
return '<div class="cred-field"><span class="cred-label">U:</span>' +
'<input type="text" name="username" value="' + esc(data.username||'') +
'" class="form-input inline-edit-input"></div>' +
'<div class="cred-field"><span class="cred-label">P:</span>' +
'<input type="password" name="password" value="' + esc(data.password||'') +
'" class="form-input inline-edit-input"></div>';
} else {
return '<input type="text" name="api_token" value="' + esc(data.api_token||'') +
'" class="form-input inline-edit-input" placeholder="API Token">';
}
}
tr.querySelectorAll('td[data-field]').forEach(function(td) {
var field = td.dataset.field;
td.dataset.orig = td.innerHTML;
var fDef = fieldMap[field];
if (fDef === undefined) return;
var inputType = fDef.input_type || 'text';
var val = rowData[field] != null ? rowData[field] : '';
if (inputType === 'checkbox') {
var checked = (val === true || val === 'true' || val === 1 || val === '1');
td.innerHTML = '<input type="checkbox" name="' + field + '"' +
(checked ? ' checked' : '') + ' class="inline-edit-checkbox">';
} else if (inputType === 'checkbox_multi') {
var opts = fDef.options || [];
var checked = [];
try { var parsed = JSON.parse(val); if (Array.isArray(parsed)) checked = parsed; } catch(ex) {}
var cbHtml = '<div class="checkbox-multi-group">';
opts.forEach(function(o) {
var isChecked = checked.indexOf(o.value) !== -1;
cbHtml += '<label class="checkbox-multi-item">' +
'<input type="checkbox" name="' + field + '" value="' + esc(o.value) + '"' +
(isChecked ? ' checked' : '') + ' class="inline-edit-checkbox-multi"> ' + esc(o.label) + '</label>';
});
cbHtml += '</div>';
td.innerHTML = cbHtml;
} else if (inputType === 'select') {
var opts = fDef.options || [];
var selHtml = '<select name="' + field + '" class="form-select inline-edit-select">';
opts.forEach(function(o) {
selHtml += '<option value="' + esc(o.value) + '"' +
(String(val) === String(o.value) ? ' selected' : '') + '>' + esc(o.label) + '</option>';
});
selHtml += '</select>';
td.innerHTML = selHtml;
} else if (inputType === 'number') {
var minAttr = fDef.min !== undefined ? ' min="' + esc(String(fDef.min)) + '"' : '';
var maxAttr = fDef.max !== undefined ? ' max="' + esc(String(fDef.max)) + '"' : '';
td.innerHTML = '<input type="number" name="' + field + '" value="' + esc(String(val)) +
'"' + minAttr + maxAttr + ' class="form-input inline-edit-input">';
} else if (inputType === 'textarea') {
var textVal;
try { var arr = JSON.parse(val); textVal = Array.isArray(arr) ? arr.join('\n') : String(val||''); }
catch(ex) { textVal = String(val||''); }
td.innerHTML = '<textarea name="' + field + '" rows="3" class="form-input inline-edit-textarea">' +
esc(textVal) + '</textarea>';
} else if (inputType === 'credentials') {
td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData);
} else {
td.innerHTML = '<input type="' + inputType + '" name="' + field +
'" value="' + esc(String(val)) + '" class="form-input inline-edit-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() {
document.querySelectorAll('form').forEach(function(form) {
var cancelBtn = form.querySelector('.btn-cancel');
if (!cancelBtn) return;
var origValues = {};
form.querySelectorAll('input, textarea, select').forEach(function(el) {
if (el.name) origValues[el.name] = el.type === 'checkbox' ? el.checked : el.value;
});
function checkChanged() {
var changed = false;
form.querySelectorAll('input, textarea, select').forEach(function(el) {
if (!el.name) return;
var cur = el.type === 'checkbox' ? el.checked : el.value;
if (cur !== origValues[el.name]) changed = true;
});
cancelBtn.disabled = !changed;
}
form.addEventListener('input', checkChanged);
form.addEventListener('change', checkChanged);
cancelBtn.addEventListener('click', function() {
form.querySelectorAll('input, textarea, select').forEach(function(el) {
if (!el.name) return;
if (el.type === 'checkbox') {
el.checked = origValues[el.name];
} else {
el.value = origValues[el.name];
}
});
cancelBtn.disabled = true;
});
});
})();
document.querySelectorAll('.editable-list').forEach(function(list) {
var name = list.dataset.name;
var ph = list.dataset.placeholder;
function attachRemove(row) {
row.querySelector('.editable-list-remove').addEventListener('click', function() {
row.remove();
});
}
list.querySelectorAll('.editable-list-item').forEach(attachRemove);
list.querySelector('.editable-list-add').addEventListener('click', function() {
var row = document.createElement('div');
row.className = 'editable-list-item';
row.innerHTML = '<input type="text" name="' + name + '" placeholder="' + ph +
'" class="form-input"><button type="button" class="btn btn-ghost btn-sm' +
' editable-list-remove">Remove</button>';
list.insertBefore(row, this);
attachRemove(row);
});
});
(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); });
});
})();
"""
# -- 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)