From 1303e0c74f027371806e66c6bc3077ca6a25b177 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Tue, 26 May 2026 15:17:36 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/view_page.py | 1927 +++++++------------------- 1 file changed, 479 insertions(+), 1448 deletions(-) diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 8322f52..9bcbf1c 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -24,7 +24,7 @@ def _passes(req, level): ('-', lambda n, l: l <= n), ('=', lambda n, l: l == n)): if req.endswith(suffix): - role = req[:-1].replace('client_is_', '', 1) + 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) @@ -93,15 +93,15 @@ def _iface_info(iface): return f.read().strip() except Exception: return None - wireless = os.path.isdir(f'{base}/wireless') - state = (_rd('operstate') or 'unknown').upper() + wireless = os.path.isdir(f'{base}/wireless') + state = (_rd('operstate') or 'unknown').upper() if state == 'UNKNOWN': - state = 'UP' + state = 'UP' carrier_raw = _rd('carrier') - carrier = (carrier_raw == '1') if carrier_raw is not None else None - speed_raw = _rd('speed') + carrier = (carrier_raw == '1') if carrier_raw is not None else None + speed_raw = _rd('speed') try: - mbps = int(speed_raw) + mbps = int(speed_raw) if mbps <= 0: speed = None elif mbps >= 1000 and mbps % 1000 == 0: @@ -110,7 +110,7 @@ def _iface_info(iface): speed = f'{mbps} Mbps' except (TypeError, ValueError): speed = None - mac = _rd('address') + mac = _rd('address') perm_mac = _rd('perm_address') if perm_mac and perm_mac == '00:00:00:00:00:00': perm_mac = None @@ -189,7 +189,7 @@ def _vlan_name_for_ip(ip): import ipaddress for vlan in _load_config().get('vlans', []): subnet = vlan.get('subnet', '') - mask = vlan.get('subnet_mask', 24) + mask = vlan.get('subnet_mask', 24) if not subnet: continue try: @@ -292,7 +292,7 @@ def _config_datasource(name): rows = [] for v in sorted(vlans, key=lambda x: validate.derive_vlan_id(x.get('subnet', ''), x.get('subnet_mask', 24)) or 0): row = {k: v.get(k) for k in ('name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries')} - row['vlan_id'] = validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) + row['vlan_id'] = validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) row['interface'] = _resolve_iface(v, cfg) row['use_blocklists'] = json.dumps([ {'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', []) @@ -322,9 +322,11 @@ def _config_datasource(name): row = dict(p) ptype = p.get('provider', '').lower() if ptype == 'noip': - row['credentials'] = (f'
' - f'U: {e(p.get("username", "-"))}
' - f'P: ••••••
') + row['credentials'] = ( + '
' + f'U: {e(p.get("username", "-"))}
' + 'P: ••••••
' + ) elif ptype in ('cloudflare', 'duckdns'): tok = p.get('api_token', '') row['credentials'] = f'API Token: {e(tok[:20])}...' if tok else '(not set)' @@ -363,6 +365,17 @@ def _config_datasource(name): return [] +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 [] + + # Live stat helpers ================================================= def _get_dnsmasq_stats(): @@ -401,7 +414,7 @@ def _count_blocked_domains(): try: total = sum( int(_run(f'wc -l < "{bl_dir}/{f}"') or 0) - for f in os.listdir(bl_dir) if f.endswith('.conf') + for f in os.listdir(bl_dir) if f.endswith('.con') ) return str(total) except Exception: @@ -412,7 +425,7 @@ def _bl_last_update(): try: mtime = max( os.path.getmtime(f'{bl_dir}/{f}') - for f in os.listdir(bl_dir) if f.endswith('.conf') + for f in os.listdir(bl_dir) if f.endswith('.con') ) return _fmt_timestamp(int(mtime)) except Exception: @@ -422,23 +435,25 @@ def _blocklist_stats_html(cfg): bl_dir = f'{CONFIGS_DIR}/blocklists' rows = '' for bl in cfg.get('dns_blocking', {}).get('blocklists', []): - name = e(bl.get('name', '')) + name = e(bl.get('name', '')) save_as = bl.get('save_as', '') bl_path = f'{bl_dir}/{save_as}' if save_as else '' try: with open(bl_path) as f: entries = sum(1 for _ in f) - mtime = int(os.path.getmtime(bl_path)) - size_str = _fmt_bytes(os.path.getsize(bl_path)) + mtime = int(os.path.getmtime(bl_path)) + size_str = _fmt_bytes(os.path.getsize(bl_path)) last_refreshed = f'{datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")} ({_relative_time(mtime)})' except Exception: entries, size_str, last_refreshed = '-', '-', 'Never' - rows += (f'' - f'{name}' - f'{entries}' - f'{size_str}' - f'{e(last_refreshed)}' - f'') + rows += ( + '' + f'{name}' + f'{entries}' + f'{size_str}' + f'{e(last_refreshed)}' + '' + ) if not rows: return '' return ( @@ -460,20 +475,22 @@ def _ddns_log_tail(): log_path = f'{CONFIGS_DIR}/ddns.log' try: log_max_kb = _load_ddns().get('general', {}).get('log_max_kb', 1024) - size_kb = os.path.getsize(log_path) / 1024 + size_kb = os.path.getsize(log_path) / 1024 with open(log_path) as f: lines = f.readlines() if not lines: return '(log is empty)', '' - total = len(lines) - tail = lines[-DDNS_LOG_MAX:] - shown = len(tail) + total = len(lines) + tail = lines[-DDNS_LOG_MAX:] + shown = len(tail) hidden = total - shown - pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 - left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines' - right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)' - summary = (f'
' - f'{left}{right}
') + pct = min(100, round(size_kb / log_max_kb * 100)) if log_max_kb else 0 + left = f'Showing {shown} of {total} lines ({hidden} not shown)' if hidden > 0 else f'Showing {shown} of {total} lines' + right = f'Log file size: {size_kb:.1f} KB ({pct}% of max)' + summary = ( + '
' + f'{left}{right}
' + ) return ''.join(tail).strip(), summary except FileNotFoundError: return '(log file not found)', '' @@ -531,7 +548,6 @@ def _public_ip_info(ddns_cfg): for p in enabled_p: all_hosts.extend(p.get('hostnames', p.get('subdomains', []))) domains_sub = ', '.join(all_hosts) - ip, mtime = _read_cached_ip() last_obtained = f'Obtained: {_relative_time(mtime)}' if mtime else '' if ip: @@ -557,17 +573,17 @@ def _vpn_info(): def collect_tokens(): tokens = {} cfg = _load_config() - net = cfg.get('network_interfaces', {}) + net = cfg.get('network_interfaces', {}) dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {}) - dns = cfg.get('upstream_dns', {}) + dns = cfg.get('upstream_dns', {}) vlans = cfg.get('vlans', []) - tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-')) - tokens['GENERAL_LAN_INTERFACE'] = str(net.get('lan_interface', '-')) - tokens['GENERAL_WAN_STATUS'] = _iface_status(net.get('wan_interface', '')) - tokens['GENERAL_LAN_STATUS'] = _iface_status(net.get('lan_interface', '')) - tokens['GENERAL_LOG_MAX_KB'] = str(dns_blk_gen.get('log_max_kb', '-')) - + tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-')) + tokens['GENERAL_LAN_INTERFACE'] = str(net.get('lan_interface', '-')) + tokens['GENERAL_WAN_STATUS'] = _iface_status(net.get('wan_interface', '')) + tokens['GENERAL_LAN_STATUS'] = _iface_status(net.get('lan_interface', '')) + tokens['GENERAL_LOG_MAX_KB'] = str(dns_blk_gen.get('log_max_kb', '-')) sys_ifaces = _get_system_interfaces() + # Always include currently-configured values so dropdowns are never blank. for configured in [net.get('wan_interface', ''), net.get('lan_interface', '')]: if configured and configured not in sys_ifaces: @@ -586,10 +602,9 @@ def collect_tokens(): default=len('Speed') ) tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed'))) - - tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false' - tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) - tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false' + tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false' + tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) + tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if session.get('apply_changes_immediately', False) else 'false' # Queue health fix before building the pending table so it appears immediately. _level = _client_level() @@ -609,9 +624,9 @@ def collect_tokens(): except Exception: pass - all_snaps = load_all_snapshots() + all_snaps = load_all_snapshots() _snap_uuid_set = {s.get('uuid') for s in all_snaps} - pending_items = get_dashboard_pending() + pending_items = get_dashboard_pending() if pending_items: # Group by command; each group = one row in the Pending Actions table. from collections import defaultdict @@ -628,17 +643,19 @@ def collect_tokens(): f'{_uuid[:8]}' f'{_uuid[:8]}' f'{_uuid[:8]}' - f'' + '' for _uuid in snap_uuids ) req_cell = f'
{req_tags}
' else: req_cell = '-' - rows += (f'' - f'{e(cmd)}' - f'{e(users)}' - f'{req_cell}' - f'') + rows += ( + '' + f'{e(cmd)}' + f'{e(users)}' + f'{req_cell}' + '' + ) pending_html = ( '' '' @@ -651,9 +668,9 @@ def collect_tokens(): ) else: pending_html = '

No pending actions.

' + tokens['PENDING_ACTIONS_HTML'] = pending_html tokens['NO_PENDING'] = 'true' if not pending_items else '' - done_ts_map = get_done_timestamps() if all_snaps: # UUIDs that cannot be reverted: revert entries themselves, and entries @@ -670,29 +687,33 @@ def collect_tokens(): 'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"' ) for snap in all_snaps: - _uuid = snap.get('uuid', '') + _uuid = snap.get('uuid', '') applied_ts = done_ts_map.get(_uuid) - dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-' - snap_desc = e(snap.get('description', '')) + dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-' + snap_desc = e(snap.get('description', '')) before_val = snap.get('before') - after_val = snap.get('after') - snap_tag = (f'
' - f'{e(_uuid[:8])}' - f'{e(_uuid[:8])}' - f'{e(_uuid[:8])}' - f'
') - snap_user = e(snap.get('user', '')) - _cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else '' - hist_rows += (f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'{_snap_expand_row(before_val, after_val, 7)}') + after_val = snap.get('after') + snap_tag = ( + f'
' + f'{e(_uuid[:8])}' + f'{e(_uuid[:8])}' + f'{e(_uuid[:8])}' + '
' + ) + snap_user = e(snap.get('user', '')) + _cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else '' + hist_rows += ( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + '' + f'{_snap_expand_row(before_val, after_val, 7)}' + ) select_all = ( '' @@ -713,51 +734,50 @@ def collect_tokens(): ) else: history_html = '

No change history.

' + tokens['CHANGE_HISTORY_HTML'] = history_html tokens['NO_HISTORY'] = 'true' if not all_snaps else '' 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_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())) - + tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans)) + tokens['STAT_LEASE_COUNT'] = str(len(_live_dhcp_leases())) filter_opts = '' + ''.join( f'' for n in vlan_names ) - tokens['VLAN_FILTER_OPTIONS'] = filter_opts - tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) - - tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn'))) - tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) for v in vlans]) - tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans]) + tokens['VLAN_FILTER_OPTIONS'] = filter_opts + tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) + tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn'))) + tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) for v in vlans]) + tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans]) tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, cfg) for v in vlans]) - tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))) - tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', []))) - tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg) + tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True))) + tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', []))) + tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg) ddns = _load_ddns() ddns_gen = ddns.get('general', {}) - tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-') + tokens['DDNS_TIMER_INTERVAL'] = ddns_gen.get('timer_interval', '-') _interval_secs = _parse_interval_to_seconds(ddns_gen.get('timer_interval', '')) or 600 tokens['DDNS_TIMER_INTERVAL_MINS'] = str(_interval_secs // 60) - tokens['DDNS_GEN_LOG_MAX_KB'] = str(ddns_gen.get('log_max_kb', 1024)) + tokens['DDNS_GEN_LOG_MAX_KB'] = str(ddns_gen.get('log_max_kb', 1024)) tokens['DDNS_GEN_LOG_ERRORS_ONLY'] = 'true' if ddns_gen.get('log_errors_only') else 'false' enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)] tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p)) _ip_check = ddns.get('ip_check_services', []) _http_svc = [s['url'] for s in _ip_check if s.get('type') == 'http'] - _dig_svc = [s['url'] for s in _ip_check if s.get('type') == 'dig'] + _dig_svc = [s['url'] for s in _ip_check if s.get('type') == 'dig'] tokens['STAT_IP_CHECK_TOTAL'] = str(len(_ip_check)) - tokens['STAT_IP_CHECK_SUB'] = f'{len(_http_svc)} http and {len(_dig_svc)} dig' - tokens['IP_CHECK_HTTP_JSON'] = json.dumps(_http_svc) - tokens['IP_CHECK_DIG_JSON'] = json.dumps(_dig_svc) + tokens['STAT_IP_CHECK_SUB'] = f'{len(_http_svc)} http and {len(_dig_svc)} dig' + tokens['IP_CHECK_HTTP_JSON'] = json.dumps(_http_svc) + tokens['IP_CHECK_DIG_JSON'] = json.dumps(_dig_svc) _ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'} tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([ {'value': p, 'label': _ddns_labels.get(p, p.title())} @@ -773,14 +793,15 @@ def collect_tokens(): {'value': v.get('name', ''), 'label': f'wg{i} (VLAN {validate.derive_vlan_id(v.get("subnet", ""), v.get("subnet_mask", 24)) or "?"})'} for i, v in enumerate(wg_vlans_list) ]) - wg_vlan = wg_vlans_list[0] if wg_vlans_list else {} - vpn = wg_vlan.get('vpn_information', {}) + wg_vlan = wg_vlans_list[0] if wg_vlans_list else {} + vpn = wg_vlan.get('vpn_information', {}) overrides = vpn.get('explicit_overrides', {}) tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', '')) tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', '')) tokens['VPN_DOMAIN'] = str(vpn.get('domain', '')) tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', '')) tokens['VPN_MTU'] = str(overrides.get('mtu', '')) + # Compute gateway from server_identities (lowest last-octet), fallback to first subnet host try: import ipaddress as _ipaddress @@ -789,7 +810,7 @@ def collect_tokens(): default_gw = str(min((_ipaddress.IPv4Address(ip) for ip in ident_ips), key=lambda x: x.packed[-1])) else: - wg_net = _ipaddress.IPv4Network( + 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 @@ -803,45 +824,39 @@ def collect_tokens(): tokens['STAT_PUBLIC_IP_LAST_OBTAINED'] = last_obtained tokens['STAT_PUBLIC_IP_LAST_CHECKED'] = _ddns_last_checked() tokens['DDNS_LOG_TAIL'], tokens['DDNS_LOG_SUMMARY'] = _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' + 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', '') + 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 cfg.get('dns_blocking', {}).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)'}, @@ -885,9 +900,11 @@ def _snap_expand_row(before_val, after_val, colspan): if isinstance(val, (dict, list)): text = json.dumps(val, indent=2) body = e(text) if text else '(none)' - return (f'
' - f'{label}' - f'
{body}
') + return ( + '
' + f'{label}' + f'
{body}
' + ) inner = f'
{box("Before", before_val)}{box("After", after_val)}
' return f'' @@ -936,7 +953,7 @@ def render_items(items, tokens, inherited_req=None): return ''.join(parts) def _render_item(item, tokens, inherited_req=None): - t = item.get('type', '') + t = item.get('type', '') req = item.get('client_requirement', inherited_req) if t == 'h1': @@ -949,7 +966,7 @@ def _render_item(item, tokens, inherited_req=None): text = e(apply_tokens(item.get('text', ''), tokens)) link = item.get('link') if link: - href = e(apply_tokens(link.get('action', '#'), tokens)) + href = e(apply_tokens(link.get('action', '#'), tokens)) ltext = e(apply_tokens(link.get('text', ''), tokens)) return f'

{text} {ltext}

' return f'

{text}

' @@ -969,25 +986,27 @@ def _render_item(item, tokens, inherited_req=None): extra = item.get('class', '') if extra: cls = f'{cls} {extra}' - text = e(apply_tokens(item.get('text', ''), tokens)) - action_raw = item.get('action', '') - action = e(apply_tokens(action_raw, tokens)) + text = e(apply_tokens(item.get('text', ''), tokens)) + action_raw = item.get('action', '') + action = e(apply_tokens(action_raw, tokens)) disabled_val = apply_tokens(str(item.get('disabled', '')), tokens) - disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else '' - formaction = item.get('formaction', '') + disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else '' + formaction = item.get('formaction', '') if formaction: formaction = e(apply_tokens(formaction, tokens)) return f'' if item.get('method', '').lower() == 'post': - return (f'' - f'') + return ( + f'' + f'' + ) if action_raw: return f'{text}' return f'' if t == 'button_cancel': - text = e(apply_tokens(item.get('text', 'Cancel'), tokens)) - extra_cls = (' ' + item['class']) if item.get('class') else '' + text = e(apply_tokens(item.get('text', 'Cancel'), tokens)) + extra_cls = (' ' + item['class']) if item.get('class') else '' return f'' if t == 'page_header': @@ -1005,91 +1024,97 @@ def _render_item(item, tokens, inherited_req=None): return f'
{render_items(item.get("items", []), tokens, req)}
' if t == 'stat_card': - label = e(apply_tokens(item.get('label', ''), tokens)) - raw_value = apply_tokens(item.get('value', ''), tokens) - value = e(raw_value) - sub = e(apply_tokens(item.get('sub', ''), tokens)) - variant = item.get('variant', '') - cls = f'stat-card{(" stat-card-" + variant) if variant else ""}' - edit_action = item.get('edit_action', '') - edit_field = item.get('edit_field', '') + label = e(apply_tokens(item.get('label', ''), tokens)) + raw_value = apply_tokens(item.get('value', ''), tokens) + value = e(raw_value) + sub = e(apply_tokens(item.get('sub', ''), tokens)) + variant = item.get('variant', '') + cls = f'stat-card{(" stat-card-" + variant) if variant else ""}' + edit_action = item.get('edit_action', '') + edit_field = item.get('edit_field', '') edit_input_type = item.get('edit_input_type', 'text') - edit_suffix = item.get('edit_suffix', '') - edit_min = item.get('edit_min', '') - edit_raw = apply_tokens(item.get('edit_value', item.get('value', '')), tokens) - reveal_card_id = item.get('reveal_card_id', '') + edit_suffix = item.get('edit_suffix', '') + edit_min = item.get('edit_min', '') + edit_raw = apply_tokens(item.get('edit_value', item.get('value', '')), tokens) + reveal_card_id = item.get('reveal_card_id', '') if reveal_card_id: return ( f'
' f'
{label}
' - f'
' + '
' f'{value}' - f'' - f'
' + '
' f'
{sub}
' - f'
' + '' ) if edit_action and edit_field: - min_attr = f' min="{e(edit_min)}"' if edit_min else '' + min_attr = f' min="{e(edit_min)}"' if edit_min else '' suffix_html = f'{e(edit_suffix)}' if edit_suffix else '' - input_wrap = (f'
' - f'' - f'{suffix_html}
') + input_wrap = ( + '
' + f'' + f'{suffix_html}
' + ) return ( f'
' f'
{label}
' - f'
' + '
' f'{value}' - f'' - f'
' + '' + '
' f'' f'' f'{input_wrap}' - f'
' - f'' - f'' - f'
' - f'' + '
' + '' + '' + '
' + '' f'
{sub}
' - f'
' + '' ) - return (f'
' - f'
{label}
' - f'
{value}
' - f'
{sub}
' - f'
') + return ( + f'
' + f'
{label}
' + f'
{value}
' + f'
{sub}
' + '
' + ) if t == 'card': - label = item.get('label', '') + label = item.get('label', '') id_attr = f' id="{e(item["id"])}"' if item.get('id') else '' cls_hidden = ' hidden' if item.get('hidden') else '' - header = f'

{e(label)}

' if label else '' - body = render_items(item.get('items', []), tokens, req) + header = f'

{e(label)}

' if label else '' + body = render_items(item.get('items', []), tokens, req) return f'
{header}
{body}
' if t == 'field_status': label = e(item.get('label', '')) - raw = apply_tokens(item.get('value', ''), tokens).upper() + raw = apply_tokens(item.get('value', ''), tokens).upper() badge_map = { 'UP': ('badge-enabled', 'Up'), 'DOWN': ('badge-warning', 'Down'), 'INVALID': ('badge-danger', 'Invalid'), } badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown')) - return (f'
' - f'' - f'
{badge_text}
' - f'
') + return ( + '
' + f'' + f'
{badge_text}
' + '
' + ) if t == 'info_bar': variant = item.get('variant', 'info') - text = e(apply_tokens(item.get('text', ''), tokens)) + text = e(apply_tokens(item.get('text', ''), tokens)) return f'
{text}
' if t == 'pre_block': - text = e(apply_tokens(item.get('text', ''), tokens)) + text = e(apply_tokens(item.get('text', ''), tokens)) extra = ' data-scroll-bottom' if item.get('scroll_to_bottom') else '' return f'
{text}
' @@ -1097,17 +1122,17 @@ def _render_item(item, tokens, inherited_req=None): psel = e(item.get('provider_select', 'provider')) return ( f'
' - f'' - f'' - f'
' + '' + '' + '' ) if t == 'grid': @@ -1124,17 +1149,19 @@ def _render_item(item, tokens, inherited_req=None): return f'
{e(apply_tokens(item.get("text", ""), tokens))}
' 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) + 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'' - originals = _collect_form_originals(item.get('items', []), tokens) - orig_field = (f'' - if originals else '') + originals = _collect_form_originals(item.get('items', []), tokens) + orig_field = ( + f'' + if originals else '' + ) return f'{hash_field}{orig_field}{inner}' if t == 'hidden': - name = e(item.get('name', '')) + name = e(item.get('name', '')) value = e(apply_tokens(item.get('value', ''), tokens)) return f'' @@ -1143,7 +1170,7 @@ def _render_item(item, tokens, inherited_req=None): if t == 'field_row': inner = render_items(item.get('items', []), tokens, req) - cols = item.get('cols', 2) + cols = item.get('cols', 2) return f'
{inner}
' if t == 'subnet_row': @@ -1160,33 +1187,33 @@ def _render_item(item, tokens, inherited_req=None): dotted = _prefix_to_dotted(pf) return ( - f'
' - f'' - f'
' + '
' + '' + '
' f'' - f'/' + '/' f'' f'{e(dotted)}' - f'
' - f'' - f'
' + '
' + '' + '
' ) 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) + 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 '' + extra = f' data-filter-col="{e(filter_col)}"' if filter_col else '' return f'' if t == 'spacer': return '' if t == 'button_row': - justify = item.get('justify', '') + justify = item.get('justify', '') style_attr = f' style="justify-content:{e(justify)}"' if justify else '' inner = render_items(item.get('items', []), tokens, req) return f'
{inner}
' @@ -1201,10 +1228,10 @@ def _render_item(item, tokens, inherited_req=None): def _render_field(item, tokens): - label = e(item.get('label', '')) - name = e(item.get('name', '')) + label = e(item.get('label', '')) + name = e(item.get('name', '')) input_type = item.get('input_type', 'text') - value = apply_tokens(item.get('value', ''), tokens) + 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'

{hint}

' if hint else '' @@ -1215,36 +1242,42 @@ def _render_field(item, tokens): return f'' if input_type == 'checkbox': - checked = 'checked' if value.lower() in ('true', '1', 'yes') else '' + checked = 'checked' if value.lower() in ('true', '1', 'yes') else '' cb_label = item.get('checkbox_label') if cb_label: label_html = f'' if label else '' - return (f'
' - f'{label_html}' - f'{hint_html}
') - return (f'
' - f'{hint_html}
') + return ( + '
' + f'{label_html}' + '{hint_html}
' + ) + return ( + '
' + '{hint_html}
' + ) if input_type == 'checkbox_group': try: - opts = json.loads(apply_tokens(item.get('options', '[]'), tokens)) + opts = json.loads(apply_tokens(item.get('options', '[]'), tokens)) selected = json.loads(value) if value else [] except Exception: opts, selected = [], [] boxes = ''.join( - f'' for o in opts ) - return (f'
' - f'
{boxes}
{hint_html}
') + return ( + f'
' + f'
{boxes}
{hint_html}
' + ) if input_type == 'select': options = item.get('options', []) @@ -1258,34 +1291,44 @@ def _render_field(item, tokens): f'' for o in options ) - return (f'
' - f'' - f'{hint_html}
') + return ( + f'
' + f'' + f'{hint_html}
' + ) 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 '' dyn_hint_html = '' - inp = (f'') + inp = ( + f'' + ) if item.get('layout') == 'inline': - return (f'
' - f'' - f'
{inp}{dyn_hint_html}
' - f'{hint_html}
') - return (f'
' - f'{inp}{dyn_hint_html}{hint_html}
') + return ( + '
' + f'' + f'
{inp}{dyn_hint_html}
' + f'{hint_html}
' + ) + return ( + f'
' + f'{inp}{dyn_hint_html}{hint_html}
' + ) if input_type == 'textarea': rows = item.get('rows', 4) - return (f'
' - f'' - f'{hint_html}
') + return ( + f'
' + f'' + f'{hint_html}
' + ) if input_type == 'interface_picker': - current = apply_tokens(item.get('value', ''), tokens) + current = apply_tokens(item.get('value', ''), tokens) try: ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens)) except Exception: @@ -1306,13 +1349,13 @@ def _render_field(item, tokens): s = val or '-' return ' ' * max(0, speed_pad - len(s)) + e(s) for ifc in ifaces: - iname = ifc.get('name', '') + iname = ifc.get('name', '') wireless = ifc.get('wireless', False) - state = ifc.get('state', 'UNKNOWN') - carrier = ifc.get('carrier') + state = ifc.get('state', 'UNKNOWN') + carrier = ifc.get('carrier') raw_speed = ifc.get('speed') - raw_mtu = ifc.get('mtu') - raw_mac = ifc.get('mac') + raw_mtu = ifc.get('mtu') + raw_mac = ifc.get('mac') speed = raw_speed or '-' mtu = raw_mtu or '-' mac = raw_mac or '-' @@ -1322,74 +1365,86 @@ def _render_field(item, tokens): carrier_txt = '-' else: carrier_txt = 'Yes' if carrier else ('No' if carrier is False else '-') - sel_cls = ' selected' if iname == current else '' + sel_cls = ' selected' if iname == current else '' if iname == current: cur_sc, cur_st = sc, st cur_speed, cur_mtu, cur_mac = raw_speed, raw_mtu, raw_mac cur_perm_mac = ifc.get('perm_mac') - cur_min_mtu = ifc.get('min_mtu') - cur_max_mtu = ifc.get('max_mtu') + cur_min_mtu = ifc.get('min_mtu') + cur_max_mtu = ifc.get('max_mtu') padded_speed = _pad_speed(raw_speed) - padded_mtu = ' ' * max(0, 4 - len(raw_mtu or '-')) + e(raw_mtu or '-') - rows_html += (f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'') - table_html = (f'
' - f'
{e(dt_str)}{snap_desc}{_render_snap_val(before_val)}{_render_snap_val(after_val)}{snap_tag}{snap_user}
{e(dt_str)}{snap_desc}{_render_snap_val(before_val)}{_render_snap_val(after_val)}{snap_tag}{snap_user}
{e(iname)}{e(type_txt)}{st}{e(carrier_txt)}{e(speed)}{e(mtu)}{e(mac)}
' - f'' - f'' - f'{rows_html}' - f'
InterfaceTypeStateCarrierSpeedMTUMAC
') + padded_mtu = ' ' * max(0, 4 - len(raw_mtu or '-')) + e(raw_mtu or '-') + rows_html += ( + f'' + f'{e(iname)}' + f'{e(type_txt)}' + f'{st}' + f'{e(carrier_txt)}' + f'{e(speed)}' + f'{e(mtu)}' + f'{e(mac)}' + '' + ) + table_html = ( + '
' + '' + '' + '' + f'{rows_html}' + '
InterfaceTypeStateCarrierSpeedMTUMAC
' + ) btn_label = f'{e(current) or "Select..."}' - btn_badge = (f'{e(cur_st)}' - if current else '') + btn_badge = ( + f'{e(cur_st)}' + if current else '' + ) if current and any([cur_speed, cur_mtu, cur_mac]): - ext_meta = (f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'
SpeedMTUMAC
{_pad_speed(cur_speed)}{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")}{e(cur_mac or "-")}
') + ext_meta = ( + '' + '' + '' + f'' + f'' + f'' + '' + '
SpeedMTUMAC
{_pad_speed(cur_speed)}{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")}{e(cur_mac or "-")}
' + ) else: ext_meta = '' configure_btn = ( - f'' + 'Configure' ) if current else '' - return (f'
' - f'' - f'
' - f'' - f'
' - f'' - f'{ext_meta}' - f'{configure_btn}' - f'
' - f'
{table_html}
' - f'
' - f'
') + return ( + '
' + f'' + '
' + f'' + '
' + f'' + f'{ext_meta}' + f'{configure_btn}' + '
' + f'
{table_html}
' + '
' + '
' + ) - validate = item.get('validate', '') + validate = item.get('validate', '') validate_attr = f' data-validate="{e(validate)}"' if validate else '' dyn_hint = '' if (item.get('readonly') or item.get('dyn_hint') or validate) else '' - return (f'
' - f'{hint_html}{dyn_hint}
') + return ( + f'
' + f'{hint_html}{dyn_hint}
' + ) def _collect_form_originals(items, tokens): @@ -1398,7 +1453,7 @@ def _collect_form_originals(items, tokens): for item in items: t = item.get('type', '') if t == 'field': - name = item.get('name', '') + name = item.get('name', '') input_type = item.get('input_type', 'text') if not name or input_type == 'hidden': continue @@ -1433,11 +1488,11 @@ def _collect_form_originals(items, tokens): 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)) + 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'

{hint}

' if hint else '' validate = e(item.get('validate', '')) @@ -1447,18 +1502,20 @@ def _render_editable_list(item, tokens): items_list = [] rows = ''.join( - f'
' + '
' f'' - f'' - f'
' + '' + '
' for v in items_list ) validate_attr = f' data-validate="{validate}"' if validate else '' - return (f'
' - f'
' - f'{rows}' - f'' - f'
{hint_html}
') + return ( + f'
' + f'
' + f'{rows}' + f'' + f'
{hint_html}
' + ) def _render_table(item, tokens, inherited_req=None): @@ -1467,7 +1524,7 @@ def _render_table(item, tokens, inherited_req=None): rows = _load_datasource(item.get('datasource', '')) empty = e(item.get('empty_message', 'No data.')) row_actions = item.get('row_actions', []) - hash_val = config_hash() + hash_val = config_hash() toolbar_html = '' toolbar = item.get('toolbar') @@ -1512,42 +1569,50 @@ def _render_table(item, tokens, inherited_req=None): 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')) + 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') + disable_if = ra.get('disable_i') if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'): btns += f'' continue - btns += (f'
' - f'' - f'' - f'
') + btns += ( + f'
' + f'' + f'' + f'
' + ) elif method == 'js_edit': - target = e(ra.get('target', 'edit-form')) + target = e(ra.get('target', 'edit-form')) row_json = e(json.dumps(row)) - btns += (f'') + btns += ( + f'' + ) elif method == 'inline_edit': fields_json = e(json.dumps(_expand_fields(ra.get('fields', []), tokens))) - row_json = e(json.dumps(row)) - btns += (f'') + row_json = e(json.dumps(row)) + btns += ( + f'' + ) else: btns += f'{text}' cells += f'{btns}' tbody += f'{cells}' - return (f'{toolbar_html}' - f'
' - f'' - f'{thead}' - f'{tbody}' - f'
') + return ( + f'{toolbar_html}' + '
' + '' + f'{thead}' + f'{tbody}' + '
' + ) def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, @@ -1576,7 +1641,7 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, inner = 'No' return f'{td_open}{inner}' - if render_fn == 'badge_recording_on_off': + if render_fn == 'badge_recording_on_of': if str(value).lower() in ('true', '1', 'yes'): inner = 'Recording On' else: @@ -1589,10 +1654,12 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, else: label = 'Disabled'; badge_cls = 'badge-disabled' if toggle_action and row_idx is not None and toggle_allowed: - inner = (f'
' - f'' - f'
') + inner = ( + f'
' + f'' + '
' + ) else: inner = f'{label}' return f'{td_open}{inner}' @@ -1618,12 +1685,14 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, if not s: return '' short = s.split('-')[0] - mini = s[0] - return (f'' - f'{e(s)}' - f'{e(short)}' - f'{e(mini)}' - f'') + mini = s[0] + return ( + f'' + f'{e(s)}' + f'{e(short)}' + f'{e(mini)}' + '' + ) tags = ''.join(_tag(t) for t in items) return f'{td_open}
{tags}
' @@ -1642,26 +1711,15 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, return f'{td_open}{e(value)}' -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() + css = _load_css() + level = _client_level() has_pending_alert = not _apply_changes_immediately() and bool(get_dashboard_pending()) titlebar_html = f'
{WEB_APP_DISPLAY_NAME}
' - navbar_html = _render_navbar(view_id, level, tokens, pending_alert=has_pending_alert) - footer_html = f'' + navbar_html = _render_navbar(view_id, level, tokens, pending_alert=has_pending_alert) + footer_html = f'' page_hash = config_hash() lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', '')) @@ -1687,11 +1745,11 @@ def render_layout(view_id, content_html, tokens): _display_user = 'Another user' if o_user in ('unknown', '') else e(o_user) if locked and lock_mtime and o_ts < lock_mtime: text = f'{_display_user}\'s changes are being applied now...' - cls = 'info-bar-warning info-bar-running' + cls = 'info-bar-warning info-bar-running' else: timing = _format_timing(secs) - text = f'{_display_user} has pending changes which will be applied {timing}.' if timing else f'{_display_user} has pending changes. The processing service is not running.' - cls = 'info-bar-warning' + text = f'{_display_user} has pending changes which will be applied {timing}.' if timing else f'{_display_user} has pending changes. The processing service is not running.' + cls = 'info-bar-warning' other_bars += f'
{text}
\n' problem_bars = '' @@ -1703,12 +1761,12 @@ def render_layout(view_id, content_html, tokens): for section in ('configurations', 'logs'): for item in st.get(section, []): if item.get('status') == 'problem': - sev = item.get('severity', 'error') + sev = item.get('severity', 'error') text = e(item.get('detail', item.get('name', ''))) grouped.setdefault(sev, []).append(text) for item in st.get('services', []): if item.get('status') == 'problem': - name = item.get('name', '') + name = item.get('name', '') utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit' exp_parts, act_parts = [], [] if not item.get('active_ok'): @@ -1717,12 +1775,14 @@ def render_layout(view_id, content_html, tokens): if not item.get('enabled_ok'): exp_parts.append(item.get('expected_enabled', 'enabled')) act_parts.append(item.get('enabled', 'unknown')) - detail = (f"The {utype} `{name}` is expected to be " - f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.") + detail = ( + f"The {utype} `{name}` is expected to be " + f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}." + ) grouped.setdefault(item.get('severity', 'error'), []).append(e(detail)) has_problems = any(items for items in grouped.values()) fix_suffix = '' - fix_uuid = None + fix_uuid = None if has_problems: if level < LEVEL_RANK['administrator']: fix_suffix = 'Please contact an administrator.' @@ -1760,56 +1820,64 @@ def render_layout(view_id, content_html, tokens): pending_bar = '' if has_pending_alert and not problem_bars and view_id != 'view_actions': - pending_bar = ('
' - 'You have actions pending. Please visit the Actions page.' - '
\n') + pending_bar = ( + '
' + 'You have actions pending. Please visit the Actions page.' + '
\n' + ) - return (f'\n\n\n' - f' \n' - f' \n' - f' {WEB_APP_DISPLAY_NAME}\n' - f' \n' - f'\n\n' - f'{titlebar_html}\n' - f'{navbar_html}\n' - f'
\n{pending_bar}{problem_bars}{other_bars}{content_html}\n
\n' - f'{footer_html}\n' - f'\n' - f'\n' - f'\n') + return ( + '\n\n\n' + ' \n' + ' \n' + f' {WEB_APP_DISPLAY_NAME}\n' + f' \n' + '\n\n' + f'{titlebar_html}\n' + f'{navbar_html}\n' + f'
\n{pending_bar}{problem_bars}{other_bars}{content_html}\n
\n' + f'{footer_html}\n' + f'\n' + f'\n' + '\n' + ) def _render_navbar(active_view, level, tokens, pending_alert=False): navbar_data = _load_json(f'{DATA_DIR}/navbar_content.json') left, right = [], [] for item in navbar_data.get('items', []): - req = item.get('client_requirement') + 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, pending_alert=pending_alert) (right if align == 'right' else left).append(frag) - return (f'') + return ( + '' + ) def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None, pending_alert=False): req = item.get('client_requirement', inherited_req) - t = item.get('type', '') + 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', '') + 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 '' - pending = ' nav-item-pending' if pending_alert and map_to == 'view_actions' else '' - cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}' + pending = ' nav-item-pending' if pending_alert and map_to == 'view_actions' else '' + cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}' if action: - return (f'
' - f'
') + return ( + f'
' + f'
' + ) if map_to: return f'{label}' return f'{label}' @@ -1818,7 +1886,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req= raw_label = item.get('label', '') if raw_label == '%MENU_LABEL%': raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View' - label = e(raw_label) + label = e(raw_label) children = '' for child in item.get('items', []): child_req = child.get('client_requirement', req) @@ -1827,1060 +1895,23 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req= children += _render_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req, pending_alert=pending_alert) if not children: return '' - return (f'') + return ( + '' + ) return '' # Inline JavaScript ================================================= def _inline_js(): - return r""" -function showCard(el) { - el.style.display = ''; - el.classList.remove('card-reveal'); - void el.offsetWidth; - el.classList.add('card-reveal'); - el.addEventListener('animationend', function() { el.classList.remove('card-reveal'); }, {once: true}); -} - -function prefixToDotted(n) { - if (n < 1 || n > 30) return ''; - var mask = ((0xFFFFFFFF << (32 - n)) >>> 0); - return [(mask >>> 24) & 0xFF, (mask >>> 16) & 0xFF, (mask >>> 8) & 0xFF, mask & 0xFF].join('.'); -} - -function deriveVlanId(subnet, prefix) { - var parts = subnet.split('.'); - if (parts.length !== 4) return null; - var octets = parts.map(function(p) { return parseInt(p, 10); }); - if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null; - var byteIdx = Math.floor((prefix - 1) / 8); - var id = octets[byteIdx]; - return (id >= 0 && id <= 4094) ? id : null; -} - -function networkBitsMessage(octets, prefix) { - var byteIdx = Math.floor((prefix - 1) / 8); - var hostBitsInActive = (prefix % 8 === 0) ? 0 : (8 - (prefix % 8)); - var activeMask = hostBitsInActive === 0 ? 0xFF : ((0xFF << hostBitsInActive) & 0xFF); - var ordinals = ['1st', '2nd', '3rd', '4th']; - var parts = []; - if (hostBitsInActive > 0 && (octets[byteIdx] & ~activeMask) !== 0) { - var step = 1 << hostBitsInActive; - var vals = []; - for (var v = 0; v < 256; v += step) vals.push(String(v)); - var valStr = vals.length <= 8 - ? vals.slice(0, -1).join(', ') + ' or ' + vals[vals.length - 1] - : 'a multiple of ' + step; - parts.push(ordinals[byteIdx] + ' quartet must be ' + valStr); - } - var badTrailing = []; - for (var i = byteIdx + 1; i < 4; i++) { - if (octets[i] !== 0) badTrailing.push(ordinals[i]); - } - if (badTrailing.length > 0) { - var nameStr = badTrailing.length === 1 - ? badTrailing[0] - : badTrailing.slice(0, -1).join(', ') + ' and ' + badTrailing[badTrailing.length - 1]; - parts.push(nameStr + ' quartet' + (badTrailing.length > 1 ? 's' : '') + ' must be 0'); - } - if (parts.length === 0) return null; - return parts.join('; ') + ' for /' + prefix; -} - -function classifyMac(s) { - if (!s) return 'empty'; - if (/[^0-9a-fA-F:]/.test(s)) return 'invalid_char'; - if (/::/.test(s)) return 'invalid_struct'; - var groups = s.split(':'); - if (groups.length > 6) return 'too_many'; - for (var i = 0; i < groups.length; i++) { - if (groups[i].length > 2) return 'invalid_group'; - } - if (groups.length === 6 && groups.every(function(g) { return g.length === 2; })) return 'complete'; - return 'incomplete'; -} - -function classifyIp(s) { - if (!s) return 'empty'; - if (/[^0-9a-fA-F:.]/.test(s)) return 'invalid_char'; - if (s.indexOf(':') !== -1) { - // IPv6 - if (/:::/.test(s) || (s.match(/::/g) || []).length > 1) return 'invalid_struct'; - var v6parts = s.split(':').filter(function(p) { return p !== ''; }); - if (!v6parts.every(function(p) { return /^[0-9a-fA-F]{1,4}$/.test(p) || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(p); })) return 'invalid'; - var fullGroups = s.replace(/[^:]/g, '').length; - if (s.indexOf('::') !== -1 || fullGroups === 7) return 'complete'; - return 'incomplete'; - } - // IPv4 - if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct'; - var parts = s.split('.'); - if (parts.length > 4) return 'invalid_struct'; - for (var i = 0; i < parts.length; i++) { - if (!parts[i]) continue; - var n = parseInt(parts[i], 10); - if (isNaN(n) || n > 255 || String(n) !== parts[i]) return 'invalid_range'; - } - if (parts.length === 4 && parts.every(function(p) { return p !== ''; })) return 'complete'; - return 'incomplete'; -} - -function classifyIpv4(s) { - if (!s) return 'empty'; - if (s.indexOf(':') !== -1) return 'invalid_struct'; - return classifyIp(s); -} - -function classifyIpv6(s) { - if (!s) return 'empty'; - if (s.indexOf('.') !== -1 && s.indexOf(':') === -1) return 'invalid_struct'; - if (s.indexOf(':') === -1) return 'incomplete'; - return classifyIp(s); -} - -function classifyUrl(s) { - if (!s) return 'empty'; - if (/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s)) return 'invalid_char'; - var sl = s.toLowerCase(); - if ('https://'.startsWith(sl) || 'http://'.startsWith(sl)) return 'incomplete'; - var sep = sl.indexOf('://'); - if (sep === -1) return 'invalid_struct'; - var scheme = sl.slice(0, sep); - if (scheme !== 'http' && scheme !== 'https') return 'invalid_struct'; - var afterScheme = s.slice(sep + 3); - if (!afterScheme) return 'incomplete'; - var hostEnd = afterScheme.search(/[/:?#]/); - var host = hostEnd === -1 ? afterScheme : afterScheme.slice(0, hostEnd); - var rest = hostEnd === -1 ? '' : afterScheme.slice(hostEnd); - if (!host) return 'incomplete'; - if (/\.\./.test(host) || host.charAt(0) === '.' || host.charAt(host.length - 1) === '.') return 'invalid_struct'; - var labels = host.split('.'); - for (var i = 0; i < labels.length; i++) { - if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(labels[i])) return 'invalid_struct'; - } - if (rest.charAt(0) === ':') { - var portMatch = rest.slice(1).match(/^\d+/); - if (!portMatch) return 'incomplete'; - if (parseInt(portMatch[0]) < 1 || parseInt(portMatch[0]) > 65535) return 'invalid_struct'; - } - return 'complete'; -} - -function classifyPort(s) { - if (!s) return 'empty'; - if (/[^0-9]/.test(s)) return 'invalid_char'; - var n = parseInt(s, 10); - if (n < 1 || n > 65535) return 'out_of_range'; - return 'complete'; -} - -function classifyIpv4Cidr(s) { - if (!s) return 'empty'; - var slash = s.indexOf('/'); - if (slash === -1) return classifyIpv4(s); - var ipCls = classifyIpv4(s.slice(0, slash)); - if (ipCls !== 'complete') return ipCls; - var prefix = s.slice(slash + 1); - if (!prefix) return 'incomplete'; - if (/[^0-9]/.test(prefix)) return 'invalid_char'; - var n = parseInt(prefix, 10); - if (n < 0 || n > 32) return 'invalid_struct'; - return 'complete'; -} - -function classifyEndpoint(s) { - if (!s) return 'empty'; - if (s.indexOf(':') !== -1) return classifyIp(s); - if (/^[0-9.]+$/.test(s)) return classifyIp(s); - return classifyDomainname(s); -} - -function classifyDashname(s) { - if (!s) return 'empty'; - if (/[^a-z0-9-]/.test(s)) return 'invalid_char'; - if (s.charAt(0) === '-') return 'invalid_struct'; - if (/--/.test(s)) return 'invalid_struct'; - if (s.charAt(s.length - 1) === '-') return 'incomplete'; - return 'complete'; -} - -function classifyDomainname(s) { - if (!s) return 'empty'; - if (/[^a-zA-Z0-9.-]/.test(s)) return 'invalid_char'; - if (s.charAt(0) === '.') return 'invalid_struct'; - if (/\.\./.test(s)) return 'invalid_struct'; - if (s.charAt(s.length - 1) === '.') return 'incomplete'; - var labels = s.split('.'); - for (var i = 0; i < labels.length; i++) { - var l = labels[i]; - if (l.charAt(0) === '-' || l.charAt(l.length - 1) === '-') return 'invalid_struct'; - } - return 'complete'; -} - -function classifyNetworkname(s) { - if (!s) return 'empty'; - if (/[^a-zA-Z0-9_-]/.test(s)) return 'invalid_char'; - if (s.charAt(0) === '-' || s.charAt(0) === '_') return 'invalid_struct'; - if (/[-_]{2,}/.test(s)) return 'invalid_struct'; - if (s.charAt(s.length - 1) === '-' || s.charAt(s.length - 1) === '_') return 'incomplete'; - return 'complete'; -} - -function classifyTime24h(s) { - if (!s) return 'empty'; - if (/[^0-9:]/.test(s)) return 'invalid_char'; - if (s.length < 5) return 'incomplete'; - if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(s)) return 'invalid_struct'; - return 'complete'; -} - -function classifyPositiveInt(s, el) { - if (el && el.validity && el.validity.badInput) return 'invalid_char'; - if (!s && s !== '0') return 'empty'; - if (/[^0-9]/.test(s)) return 'invalid_char'; - var n = parseInt(s, 10); - var min = (el && el.min !== '') ? parseInt(el.min, 10) : 0; - var max = (el && el.max !== '') ? parseInt(el.max, 10) : null; - if (n < min || (max !== null && n > max)) return 'out_of_range'; - return 'complete'; -} - -function classifySubnet(s) { - if (!s) return 'empty'; - if (/[^0-9.]/.test(s)) return 'invalid_char'; - if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct'; - var parts = s.split('.'); - if (parts.length > 4) return 'too_many'; - for (var i = 0; i < parts.length; i++) { - var p = parts[i]; - if (!p) continue; - var n = parseInt(p, 10); - if (isNaN(n) || n > 255) return 'range'; - } - if (parts.length < 4 || parts[3] === '') return 'incomplete'; - return 'complete'; -} - -function setFieldHint(input, message, state) { - // state: 'error' | 'warning' | 'ok' - var fg = input.closest('.form-group'); - var hintContainer = fg || input.parentElement; - if (hintContainer) { - var hint = hintContainer.querySelector('.field-dyn-hint'); - if (hint) { - hint.textContent = message; - hint.style.display = message ? '' : 'none'; - hint.style.color = (state === 'error') ? 'var(--danger)' : 'var(--text-muted)'; - } - } - input.classList.remove('field-invalid', 'field-warning'); - if (state === 'error' && message) input.classList.add('field-invalid'); - else if (state === 'warning') input.classList.add('field-warning'); -} - -function updateAddVlanForm(form) { - var nameInp = form.querySelector('input[name="name"]'); - var subnetInp = form.querySelector('input[name="subnet"]'); - var prefixInp = form.querySelector('input.subnet-prefix-input'); - var vpnChk = form.querySelector('input[name="is_vpn"]'); - var ifacePrev = form.querySelector('.vlan-iface-preview'); - var derivedPrev = form.querySelector('.vlan-derived-id-preview'); - var submitBtn = form.querySelector('.add-vlan-btn'); - if (!subnetInp || !prefixInp) return; - - var subnet = subnetInp.value.trim(); - var prefix = parseInt(prefixInp.value, 10); - var isVpn = vpnChk && vpnChk.checked; - var lan = typeof LAN_IFACE !== 'undefined' ? LAN_IFACE : 'eth0'; - var sClass = classifySubnet(subnet); - var id = (sClass === 'complete') ? deriveVlanId(subnet, prefix) : null; - - // Derived VLAN ID preview - if (derivedPrev) derivedPrev.value = (id !== null) ? String(id) : ''; - - // Interface preview - var ifaceVal = ''; - if (isVpn) { - ifaceVal = 'wg' + (typeof VPN_VLAN_COUNT !== 'undefined' ? VPN_VLAN_COUNT : 0); - } else if (id !== null) { - ifaceVal = (id === 1) ? lan : lan + '.' + id; - } - if (ifacePrev) ifacePrev.value = ifaceVal; - - // Subnet sub-text + colour - var subnetMsg = '', subnetState = 'ok', subnetOk = false; - if (sClass === 'empty' || sClass === 'incomplete') { - subnetState = 'warning'; - } else if (sClass === 'invalid_char' || sClass === 'invalid_struct' || sClass === 'too_many') { - subnetMsg = 'Invalid'; subnetState = 'error'; - } else if (sClass === 'range') { - subnetMsg = 'Quartet out of range'; subnetState = 'error'; - } else { - var octetsArr = subnet.split('.').map(Number); - var hostMsg = networkBitsMessage(octetsArr, prefix); - if (hostMsg) { - subnetMsg = hostMsg; subnetState = 'error'; - } else if (id === 0) { - subnetMsg = 'Would compute to VLAN ID 0 (reserved)'; subnetState = 'error'; - } else if (id === null || EXISTING_VLAN_IDS.indexOf(id) !== -1) { - subnetMsg = id === null ? '' : 'Duplicate'; subnetState = id === null ? 'warning' : 'error'; - } else { - subnetOk = true; - } - } - setFieldHint(subnetInp, subnetMsg, subnetState); - - // Interface duplicate/reserved sub-text - if (ifacePrev) { - if (id === 0 && !isVpn) { - setFieldHint(ifacePrev, 'Reserved', 'error'); - } else { - var ifaceDupe = ifaceVal.length > 0 && EXISTING_VLAN_INTERFACES.indexOf(ifaceVal) !== -1; - setFieldHint(ifacePrev, ifaceDupe ? 'Duplicate' : '', ifaceDupe ? 'error' : 'ok'); - } - } - - // VLAN ID duplicate/reserved sub-text - if (derivedPrev) { - if (id === 0) { - setFieldHint(derivedPrev, 'Reserved', 'error'); - } else { - var derivedDupe = id !== null && EXISTING_VLAN_IDS.indexOf(id) !== -1; - setFieldHint(derivedPrev, derivedDupe ? 'Duplicate' : '', derivedDupe ? 'error' : 'ok'); - } - } - - // Name validation + colour - if (submitBtn) { - var name = nameInp ? nameInp.value.trim().toLowerCase() : ''; - var nameValid = name.length > 0 && /^[a-z0-9-]+$/.test(name); - var nameDupe = nameValid && EXISTING_VLAN_NAMES.indexOf(name) !== -1; - var nameOk = nameValid && !nameDupe; - if (nameInp) { - nameInp.classList.remove('field-invalid', 'field-warning'); - if (name.length === 0) nameInp.classList.add('field-warning'); - else if (!nameOk) nameInp.classList.add('field-invalid'); - } - submitBtn.disabled = !(nameOk && subnetOk); - } -} - -document.addEventListener('input', function(e) { - var wrap = e.target.closest('.subnet-row-wrap'); - if (wrap) { - var dotLabel = wrap.querySelector('.subnet-dotted'); - if (dotLabel) { - var n = parseInt(wrap.querySelector('.subnet-prefix-input').value, 10); - dotLabel.textContent = (n >= 1 && n <= 30) ? prefixToDotted(n) : ''; - } - } - var form = e.target.closest('form'); - if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form); -}); - -document.addEventListener('change', function(e) { - if (e.target.name !== 'is_vpn') return; - var form = e.target.closest('form'); - if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form); -}); - -document.querySelectorAll('.row-edit-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - var row = JSON.parse(this.dataset.row); - var idx = this.dataset.rowIndex; - var target = document.getElementById(this.dataset.target); - if (!target) return; - var idxField = target.querySelector('[name="row_index"]'); - if (idxField) idxField.value = idx; - Object.keys(row).forEach(function(key) { - var field = target.querySelector('[name="' + key + '"]'); - if (!field) return; - if (field.type === 'checkbox') { - field.checked = row[key] === true || row[key] === 'true' || row[key] === 1; - } else { - field.value = row[key] != null ? String(row[key]) : ''; - } - }); - showCard(target); - target.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - }); -}); - -document.addEventListener('click', function(e) { - var btn = e.target.closest('.row-inline-edit-btn'); - if (!btn) return; - var rowData = JSON.parse(btn.dataset.row); - var idx = btn.dataset.rowIndex; - var action = btn.dataset.action; - var fields = JSON.parse(btn.dataset.fields); - var tr = btn.closest('tr'); - var fieldMap = {}; - fields.forEach(function(f) { fieldMap[f.col] = f; }); - - function esc(s) { - return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); - } - - function buildCredentialsHtml(provider, data) { - if (provider === 'noip') { - return '
U:' + - '
' + - '
P:' + - '
'; - } else { - return ''; - } - } - - 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'); - var cbLabel = fDef.checkbox_label ? ' ' + esc(fDef.checkbox_label) + '' : ''; - td.innerHTML = ''; - } 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 = '
'; - opts.forEach(function(o) { - var isChecked = checked.indexOf(o.value) !== -1; - cbHtml += ''; - }); - cbHtml += '
'; - td.innerHTML = cbHtml; - } else if (inputType === 'select') { - var opts = fDef.options || []; - var selHtml = ''; - 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 = '' + - ''; - if (typeof validateEl === 'function') validateEl(td.querySelector('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 = ''; - } else if (inputType === 'credentials') { - td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData); - } else { - var validateAttr = fDef.validate ? ' data-validate="' + esc(fDef.validate) + '"' : ''; - var hintHtml = fDef.validate ? '' : ''; - td.innerHTML = '' + hintHtml; - if (fDef.validate && typeof validateEl === 'function') validateEl(td.querySelector('input')); - } - }); - - var providerTd = tr.querySelector('td[data-field="provider"]'); - var credsTd = tr.querySelector('td[data-field="credentials"]'); - if (providerTd && credsTd) { - var provSel = providerTd.querySelector('select'); - if (provSel) { - provSel.addEventListener('change', function() { - credsTd.innerHTML = buildCredentialsHtml(this.value, rowData); - }); - } - } - - var actTd = tr.querySelector('.col-actions'); - if (actTd) { - actTd.dataset.origActions = actTd.innerHTML; - actTd.innerHTML = - '' + - ' '; - - actTd.querySelector('.inline-save-btn').addEventListener('click', function() { - var f = document.createElement('form'); - f.method = 'post'; - f.action = this.dataset.action; - f.style.display = 'none'; - var addHidden = function(name, value) { - var inp = document.createElement('input'); - inp.type = 'hidden'; inp.name = name; inp.value = value; - f.appendChild(inp); - }; - addHidden('row_index', this.dataset.rowIndex); - addHidden('config_hash', typeof CONFIG_HASH !== 'undefined' ? CONFIG_HASH : ''); - tr.querySelectorAll('td[data-field] input[name], td[data-field] textarea[name], td[data-field] select[name]').forEach(function(inp) { - if (inp.type === 'checkbox') { - if (inp.classList.contains('inline-edit-checkbox-multi')) { - if (inp.checked) addHidden(inp.name, inp.value); - } else { - if (inp.checked) addHidden(inp.name, 'on'); - } - } else { - addHidden(inp.name, inp.value); - } - }); - document.body.appendChild(f); - f.submit(); - }); - - actTd.querySelector('.inline-cancel-btn').addEventListener('click', function() { - tr.querySelectorAll('td[data-field]').forEach(function(td) { - if (td.dataset.orig !== undefined) td.innerHTML = td.dataset.orig; - }); - actTd.innerHTML = actTd.dataset.origActions; - }); - } -}); - -document.querySelectorAll('select[data-filter-col]').forEach(function(sel) { - function applyFilter() { - var col = sel.dataset.filterCol; - var val = sel.value; - var toolbar = sel.closest('.table-toolbar'); - if (!toolbar) return; - var wrapper = toolbar.nextElementSibling; - if (!wrapper || !wrapper.classList.contains('table-wrapper')) return; - wrapper.querySelectorAll('tbody tr').forEach(function(tr) { - if (val === 'all') { - tr.style.display = ''; - } else { - var td = tr.querySelector('td[data-field="' + col + '"]'); - tr.style.display = (td && td.textContent.trim() === val) ? '' : 'none'; - } - }); - } - sel.addEventListener('change', applyFilter); -}); - -document.querySelectorAll('.js-hide-card').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.preventDefault(); - var card = this.closest('.card'); - if (card) card.style.display = 'none'; - }); -}); - -function _elMakeRow(list, value) { - var row = document.createElement('div'); - row.className = 'editable-list-item'; - var inp = document.createElement('input'); - inp.type = 'text'; inp.name = list.dataset.name; inp.value = value; - inp.placeholder = list.dataset.placeholder || ''; inp.className = 'form-input'; - var btn = document.createElement('button'); - btn.type = 'button'; btn.className = 'btn btn-ghost btn-sm editable-list-remove'; - btn.textContent = 'Remove'; - btn.addEventListener('click', function() { row.remove(); }); - row.appendChild(inp); row.appendChild(btn); - return row; -} - -document.querySelectorAll('.editable-list').forEach(function(list) { - list.querySelectorAll('.editable-list-item').forEach(function(row) { - row.querySelector('.editable-list-remove').addEventListener('click', function() { row.remove(); }); - }); - list.querySelector('.editable-list-add').addEventListener('click', function() { - list.insertBefore(_elMakeRow(list, ''), this); - }); -}); - -var validateEl; -(function() { - var _ipMsgs = { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', - invalid_range: 'Octet out of range', invalid: 'Invalid IP address' }; - var _msgs = { - ip: _ipMsgs, - ipv4: _ipMsgs, - ipv6: _ipMsgs, - mac: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', - too_many: 'Too many groups', invalid_group: 'Each group must be exactly 2 hex characters' }, - subnet: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', - range: 'Octet out of range' }, - url: { invalid_char: 'Invalid character', invalid_struct: 'Invalid URL format' }, - port: { invalid_char: 'Digits only', out_of_range: 'Must be between 1 and 65535' }, - ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0-32', - invalid_range: 'Octet out of range' }, - endpoint: { invalid_char: 'Invalid character', invalid_struct: 'Invalid hostname or IP', - invalid_range: 'Octet out of range', invalid: 'Invalid IP address' }, - dashname: { invalid_char: 'Lowercase letters, digits and hyphens only', - invalid_struct: 'No leading, trailing or consecutive hyphens' }, - domainname: { invalid_char: 'Letters, digits, hyphens and dots only', - invalid_struct: 'Invalid domain format' }, - networkname: { invalid_char: 'Letters, digits, hyphens and underscores only', - invalid_struct: 'No leading, trailing or consecutive special characters' }, - time_24h: { invalid_char: 'Digits and colon only', invalid_struct: 'Must be HH:MM in 24-hour format (e.g. 02:30)' }, - positive_int: { invalid_char: 'Digits only', - out_of_range: function(el) { - var mn = (el && el.min !== '') ? el.min : null; - var mx = (el && el.max !== '') ? el.max : null; - if (mn !== null && mx !== null) return 'Must be between ' + mn + ' and ' + mx; - if (mn !== null) return 'Must be ≥ ' + mn; - if (mx !== null) return 'Must be ≤ ' + mx; - return 'Out of range'; - }} - }; - var _classifiers = { ip: classifyIp, ipv4: classifyIpv4, ipv6: classifyIpv6, mac: classifyMac, - subnet: classifySubnet, url: classifyUrl, - port: classifyPort, ipv4cidr: classifyIpv4Cidr, - endpoint: classifyEndpoint, - dashname: classifyDashname, domainname: classifyDomainname, networkname: classifyNetworkname, - time_24h: classifyTime24h, positive_int: classifyPositiveInt }; - - validateEl = function(el) { - var list = el.closest('.editable-list[data-validate]'); - var vtype = el.dataset.validate || (list ? list.dataset.validate : ''); - var classify = _classifiers[vtype]; - if (!classify) return; - var cls = classify(el.value, el); - if (list) { - el.classList.remove('field-invalid', 'field-warning'); - if (cls === 'incomplete') el.classList.add('field-warning'); - else if (cls !== 'empty' && cls !== 'complete') el.classList.add('field-invalid'); - } else { - var msgs = _msgs[vtype] || {}; - if (cls === 'complete' || cls === 'empty') { - setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'ok'); - } else if (cls === 'incomplete') { - setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'warning'); - } else { - var msgVal = msgs[cls]; - setFieldHint(el, typeof msgVal === 'function' ? msgVal(el) : (msgVal || 'Invalid'), 'error'); - } - } - }; - - // Regular fields (not inside editable lists) - initial state + expose _triggerValidate - document.querySelectorAll('input[data-validate]').forEach(function(el) { - if (el.closest('.editable-list')) return; - el._triggerValidate = function() { validateEl(el); }; - validateEl(el); - }); - - // Document-level delegation for regular fields (covers static + dynamically added inputs) - document.addEventListener('input', function(ev) { - var el = ev.target; - if (el.tagName !== 'INPUT' || !el.dataset.validate || el.closest('.editable-list')) return; - validateEl(el); - }); - - // Editable lists: validate existing items, delegation + MutationObserver for added items - document.querySelectorAll('.editable-list[data-validate]').forEach(function(list) { - if (!_classifiers[list.dataset.validate]) return; - list.querySelectorAll('input').forEach(function(inp) { validateEl(inp); }); - list.addEventListener('input', function(ev) { - if (ev.target.tagName === 'INPUT') validateEl(ev.target); - }); - new MutationObserver(function(mutations) { - mutations.forEach(function(m) { - m.addedNodes.forEach(function(node) { - if (node.nodeType !== 1) return; - var inp = node.querySelector ? node.querySelector('input') : null; - if (inp) validateEl(inp); - }); - }); - }).observe(list, {childList: true}); - }); -})(); - -(function() { - document.querySelectorAll('form').forEach(function(form) { - var origInput = form.querySelector('input[name="original_values"]'); - if (!origInput) return; - var original; - try { original = JSON.parse(origInput.value); } catch(ex) { return; } - - var submitBtns = form.querySelectorAll('button[type="submit"]'); - var cancelBtns = form.querySelectorAll('.btn-cancel'); - submitBtns.forEach(function(b) { b.disabled = true; }); - cancelBtns.forEach(function(b) { b.disabled = true; }); - - // Only track fields named in original - naturally excludes config_hash, - // row_index, etc., while including hidden inputs (e.g. picker values). - function snapshot() { - var state = {}; - Object.keys(original).forEach(function(k) { if (Array.isArray(original[k])) state[k] = []; }); - form.querySelectorAll('input, select, textarea').forEach(function(el) { - if (!el.name || !(el.name in original)) return; - var val = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value; - if (Array.isArray(state[el.name])) { state[el.name].push(val); } - else if (Array.isArray(original[el.name])) { state[el.name] = [val]; } - else { state[el.name] = val; } - }); - return JSON.stringify(state); - } - - var baseSnap = snapshot(); - - function checkDirty() { - var dirty = snapshot() !== baseSnap; - submitBtns.forEach(function(b) { b.disabled = !dirty; }); - cancelBtns.forEach(function(b) { b.disabled = !dirty; }); - } - - function resetToBase() { - // Reset editable lists (DOM rebuild) - form.querySelectorAll('.editable-list').forEach(function(list) { - var addBtn = list.querySelector('.editable-list-add'); - list.querySelectorAll('.editable-list-item').forEach(function(r) { r.remove(); }); - (original[list.dataset.name] || []).forEach(function(v) { - list.insertBefore(_elMakeRow(list, v), addBtn); - }); - }); - // Reset all tracked inputs; dispatch change so custom widgets update themselves - form.querySelectorAll('input, select, textarea').forEach(function(el) { - if (!el.name || !(el.name in original) || el.closest('.editable-list')) return; - var orig = original[el.name]; - var newVal = orig !== undefined ? String(orig) : ''; - if (el.type === 'checkbox') { - el.checked = (orig === '1'); - el.dispatchEvent(new Event('change', {bubbles: true})); - } else if (el.value !== newVal) { - el.value = newVal; - el.dispatchEvent(new Event('change', {bubbles: true})); - } - }); - checkDirty(); - form.querySelectorAll('input[data-validate]').forEach(function(el) { - if (typeof validateEl === 'function') validateEl(el); - }); - } - - cancelBtns.forEach(function(b) { b.addEventListener('click', resetToBase); }); - form.addEventListener('input', checkDirty); - form.addEventListener('change', checkDirty); - new MutationObserver(checkDirty).observe(form, {childList: true, subtree: true}); - - form._resetDirtyState = function() { - baseSnap = snapshot(); - submitBtns.forEach(function(b) { b.disabled = true; }); - cancelBtns.forEach(function(b) { b.disabled = true; }); - }; - }); -})(); - -(function() { - function updateCredFields(container, provider) { - var tokenGrp = container.querySelector('.cred-group-token'); - var noipGrp = container.querySelector('.cred-group-noip'); - if (!tokenGrp || !noipGrp) return; - tokenGrp.style.display = (provider === 'noip') ? 'none' : ''; - noipGrp.style.display = (provider === 'noip') ? '' : 'none'; - } - document.querySelectorAll('.credential-fields').forEach(function(container) { - var selName = container.dataset.providerSelect; - var form = container.closest('form'); - if (!form || !selName) return; - var sel = form.querySelector('[name="' + selName + '"]'); - if (!sel) return; - updateCredFields(container, sel.value); - sel.addEventListener('change', function() { updateCredFields(container, this.value); }); - }); -})(); - -function timingPhrase(n) { - return n <= 5 ? 'momentarily' - : n < 60 ? 'in about ' + n + ' seconds' - : 'in about ' + Math.round(n / 60) + ' minute' + (Math.round(n / 60) !== 1 ? 's' : ''); -} - -function startPoller(uuid, handlers) { - var nextIn = null; - var pollTimer = null; - var tickTimer = null; - - function onStatus(data) { - if (data.status === 'complete') { - clearTimeout(pollTimer); clearTimeout(tickTimer); - handlers.onComplete(); return; - } - if (data.status === 'running') { - handlers.onRunning(); - } else { - if (data.next_in !== null && data.next_in !== undefined) { nextIn = data.next_in; } - handlers.onPending(nextIn); - } - pollTimer = setTimeout(doPoll, 3000); - } - - function doPoll() { - fetch('/api/apply-health?uuid=' + encodeURIComponent(uuid)) - .then(function(r) { return r.json(); }) - .then(onStatus) - .catch(function() { pollTimer = setTimeout(doPoll, 3000); }); - } - - function tick() { - if (nextIn !== null && nextIn > 0) { nextIn--; handlers.onPending(nextIn); } - tickTimer = setTimeout(tick, 1000); - } - - doPoll(); - tick(); -} - -function startApplyPoller(uuid, bar, mine) { - function user() { var u = bar.getAttribute('data-apply-user') || ''; return (u === 'unknown' || u === '') ? 'Another user' : u; } - function esc(s) { return s.replace(/&/g,'&').replace(//g,'>'); } - startPoller(uuid, { - onPending: function(nextIn) { - bar.classList.remove('info-bar-running'); - bar.innerHTML = nextIn === null - ? (mine ? 'Your changes are pending. The processing service is not running.' - : esc(user()) + ' has pending changes. The processing service is not running.') - : (mine ? 'Your changes will be applied ' + timingPhrase(nextIn) + '.' - : esc(user()) + ' has pending changes which will be applied ' + timingPhrase(nextIn) + '.'); - }, - onRunning: function() { - bar.classList.add('info-bar-running'); - bar.innerHTML = mine ? 'Your changes are being applied now...' - : esc(user()) + '\'s changes are being applied now...'; - }, - onComplete: function() { - bar.classList.remove('info-bar-running'); - bar.innerHTML = mine ? 'Your changes have been applied.' - : esc(user()) + '\'s changes have been applied.'; - } - }); -} - -(function() { - if (typeof APPLY_UUID !== 'undefined' && APPLY_UUID) { - var bar = document.querySelector('.info-bar-flash.info-bar-success'); - if (bar) startApplyPoller(APPLY_UUID, bar, true); - } - document.querySelectorAll('[data-apply-uuid]').forEach(function(bar) { - startApplyPoller(bar.getAttribute('data-apply-uuid'), bar, false); - }); - document.querySelectorAll('[data-health-uuid]').forEach(function(el) { - var bar = el.closest('.info-bar'); - startPoller(el.getAttribute('data-health-uuid'), { - onPending: function(nextIn) { - if (bar) bar.classList.remove('info-bar-running'); - el.textContent = nextIn === null ? 'Fix pending. The processing service is not running.' - : 'Fix will be applied ' + timingPhrase(nextIn) + '.'; - }, - onRunning: function() { - if (bar) bar.classList.add('info-bar-running'); - el.textContent = 'Fix is being applied now...'; - }, - onComplete: function() { - if (bar) { - bar.classList.remove('info-bar-running'); - bar.style.animation = 'none'; - bar.offsetHeight; - bar.style.animation = ''; - } - el.textContent = 'Fix has been applied.'; - setTimeout(function() { window.location.reload(); }, 2500); - } - }); - }); -})(); - -(function() { - function closeAll() { - document.querySelectorAll('.iface-picker-dropdown.open').forEach(function(d) { - d.classList.remove('open'); - }); - } - document.querySelectorAll('.iface-picker').forEach(function(picker) { - var btn = picker.querySelector('.iface-picker-btn'); - var header = picker.querySelector('.iface-picker-header'); - var dropdown = picker.querySelector('.iface-picker-dropdown'); - var hidden = picker.querySelector('input[type="hidden"]'); - - function applySelection(iface) { - var row = dropdown.querySelector('.iface-picker-row[data-iface="' + iface + '"]'); - if (!row) return; - btn.querySelector('.iface-picker-name').textContent = iface; - var badge = btn.querySelector('.iface-picker-badge'); - if (!badge) { badge = document.createElement('span'); btn.appendChild(badge); } - badge.className = 'badge ' + row.dataset.stateClass + ' iface-picker-badge'; - badge.textContent = row.dataset.stateLabel; - var stats = header.querySelector('.iface-picker-stats'); - if (!stats) { - stats = document.createElement('table'); - stats.className = 'iface-picker-stats'; - stats.innerHTML = 'SpeedMTUMAC'; - header.appendChild(stats); - } - stats.querySelector('tbody tr').innerHTML = - '' + (row.dataset.speed || '-') + '' - + '' + (row.dataset.mtu || '-') + '' - + '' + (row.dataset.mac || '-') + ''; - dropdown.querySelectorAll('.iface-picker-row').forEach(function(r) { - r.classList.toggle('selected', r === row); - }); - } - - hidden.addEventListener('change', function() { applySelection(hidden.value); }); - - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var wasOpen = dropdown.classList.contains('open'); - closeAll(); - if (!wasOpen) dropdown.classList.add('open'); - }); - dropdown.addEventListener('click', function(e) { e.stopPropagation(); }); - dropdown.querySelectorAll('.iface-picker-row').forEach(function(row) { - row.addEventListener('click', function() { - hidden.value = this.dataset.iface; - closeAll(); - hidden.dispatchEvent(new Event('change', {bubbles: true})); - }); - }); - }); - document.addEventListener('click', closeAll); - document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') closeAll(); - }); - document.querySelectorAll('.iface-configure-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - var card = document.getElementById('iface-config-card'); - if (!card) return; - var form = card.querySelector('form'); - if (!form) return; - form.querySelector('[name="iface"]').value = this.dataset.iface; - var minMtu = this.dataset.minMtu !== '' ? parseInt(this.dataset.minMtu) : null; - var maxMtu = this.dataset.maxMtu !== '' ? parseInt(this.dataset.maxMtu) : null; - var mtuSel = form.querySelector('[name="mtu"]'); - var originalMtu = this.dataset.mtu || ''; - if (mtuSel) { - Array.from(mtuSel.options).forEach(function(opt) { - var v = parseInt(opt.value); - var out = !isNaN(v) && ((minMtu !== null && v < minMtu) || (maxMtu !== null && v > maxMtu)); - opt.disabled = out; - opt.hidden = out; - }); - mtuSel.value = originalMtu; - if (!mtuSel.value || mtuSel.selectedOptions[0].disabled) { - var first = Array.from(mtuSel.options).find(function(o) { return !o.disabled; }); - if (first) mtuSel.value = first.value; - originalMtu = mtuSel.value; - } - } - var origMtuField = form.querySelector('[name="original_mtu"]'); - if (origMtuField) origMtuField.value = originalMtu; - var macInput = form.querySelector('[name="mac"]'); - var originalMac = this.dataset.mac || ''; - if (macInput) { - macInput.dataset.permMac = this.dataset.permMac || ''; - macInput.value = originalMac; - if (macInput._triggerValidate) macInput._triggerValidate(); - } - var origMacField = form.querySelector('[name="original_mac"]'); - if (origMacField) origMacField.value = originalMac; - if (form._resetDirtyState) form._resetDirtyState(); - showCard(card); - card.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - }); - }); - document.querySelectorAll('.iface-config-cancel').forEach(function(a) { - a.addEventListener('click', function(ev) { - ev.preventDefault(); - var card = document.getElementById('iface-config-card'); - if (card) card.style.display = 'none'; - }); - }); -})(); -(function() { - var card = document.getElementById('iface-config-card'); - if (!card) return; - var macInput = card.querySelector('input[name="mac"]'); - if (!macInput || !macInput._triggerValidate) return; - macInput._postValidate = function() { - return macInput.dataset.permMac ? 'Factory default: ' + macInput.dataset.permMac : ''; - }; - macInput._triggerValidate(); -})(); -document.querySelectorAll('.pre-block[data-scroll-bottom]').forEach(function(el) { - el.scrollTop = el.scrollHeight; -}); -document.querySelectorAll('[data-reveal-card]').forEach(function(btn) { - btn.addEventListener('click', function() { - var card = document.getElementById(btn.dataset.revealCard); - if (!card) return; - if (card.style.display === 'none') { showCard(card); } else { card.style.display = 'none'; } - }); -}); -(function() { - document.querySelectorAll('.stat-card-editable').forEach(function(card) { - var form = card.querySelector('.stat-card-edit-form'); - var input = form ? form.querySelector('input[data-original]') : null; - var saveBtn = form ? form.querySelector('button[type="submit"]') : null; - function updateSave() { - if (input && saveBtn) saveBtn.disabled = (input.value === input.dataset.original); - } - if (input) input.addEventListener('input', updateSave); - card.querySelector('.stat-card-edit-btn').addEventListener('click', function() { - card.querySelector('.stat-card-view').style.display = 'none'; - form.style.display = ''; - }); - form && form.querySelector('.stat-card-cancel-btn').addEventListener('click', function() { - card.querySelector('.stat-card-view').style.display = ''; - form.style.display = 'none'; - if (input) { input.value = input.dataset.original; updateSave(); } - }); - }); -})(); - -(function() { - var revertForm = document.querySelector('form[action="/action/actions_cardhistory_revertselected"]'); - if (revertForm) { - var revertBtn = revertForm.querySelector('button[type="submit"]'); - if (revertBtn && !revertBtn.disabled) { - function updateRevertBtn() { - var any = revertForm.querySelectorAll('input[name="selected_uuids"]:checked:not(:disabled)').length > 0; - revertBtn.disabled = !any; - } - updateRevertBtn(); - revertForm.addEventListener('change', updateRevertBtn); - } - } -})(); - -(function() { - function uuidHighlight(on) { - return function() { - var uuid = this.dataset.uuid; - if (!uuid) return; - document.querySelectorAll('[data-uuid="' + uuid + '"]').forEach(function(el) { - el.classList.toggle('uuid-highlight', on); - }); - }; - } - document.addEventListener('mouseover', function(e) { - var tag = e.target.closest('.tag[data-uuid]'); - if (tag) uuidHighlight(true).call(tag); - }); - document.addEventListener('mouseout', function(e) { - var tag = e.target.closest('.tag[data-uuid]'); - if (tag) uuidHighlight(false).call(tag); - }); -})(); -""" + try: + with open(f'{DATA_DIR}/app.js') as f: + return f.read() + except Exception: + return '' # Routes ============================================================ @@ -2902,7 +1933,7 @@ def _serve_view(view_id): abort(404) view_req = view_def.get('client_requirement') - level = _client_level() + level = _client_level() if not _passes(view_req, level): return redirect('/view/view_overview' if level > 0 else '/view/view_log_in') @@ -2910,7 +1941,7 @@ def _serve_view(view_id): flash_html = '' for category, message in get_flashed_messages(with_categories=True): - variant = {'error': 'danger', 'warning': 'warning', 'success': 'success'}.get(category, 'info') + variant = {'error': 'danger', 'warning': 'warning', 'success': 'success'}.get(category, 'info') msg_html = message if isinstance(message, Markup) else e(message) flash_html += f'
{msg_html}
'