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

1210 lines
48 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
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 ''
# -- 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('dhcp', {}).get('subnet', '')
if not subnet:
continue
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', 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 == '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':
rows = []
for v in vlans:
row = {k: v.get(k) for k in ('vlan_id', 'name', 'interface', 'dhcp', 'radius_default', 'mdns_reflection')}
row['subnet'] = v.get('dhcp', {}).get('subnet', '')
row['use_blocklists'] = json.dumps(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
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_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
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 'dhcp' in v]
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['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
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))
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
{'value': 'noip', 'label': 'No-IP'},
{'value': 'cloudflare', 'label': 'Cloudflare'},
{'value': 'duckdns', 'label': 'DuckDNS'},
])
vpn = _vpn_info()
overrides = vpn.get('explicit_overrides', {})
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
tokens['VPN_GATEWAY'] = str(vpn.get('gateway', ''))
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
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]
)
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 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))
if item.get('method', '').lower() == 'post':
return (f'<form method="post" action="{action}" style="display:inline">'
f'<button type="submit" class="btn {e(cls)}">{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 == 'editable_list':
return _render_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
return f'<select name="{name}" class="form-select">{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 ''
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 ''
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">'
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>')
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">{hint_html}</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':
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]
tags = ''.join(f'<span class="tag">{e(str(t))}</span>' for t in items if str(t).strip())
return f'{td_open}<div class="tag-list">{tags}</div></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()
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}";</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"""
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 === '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 === '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.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('.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)