UI improvement
This commit is contained in:
parent
575edc836d
commit
9a272ee959
16 changed files with 2477 additions and 1604 deletions
|
|
@ -67,6 +67,46 @@ def _run(cmd):
|
|||
return ''
|
||||
|
||||
|
||||
def _prefix_to_dotted(n):
|
||||
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
|
||||
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
|
||||
|
||||
|
||||
def _get_system_interfaces():
|
||||
"""Return sorted list of physical-ish interface names from `ip link show`."""
|
||||
out = _run('ip link show')
|
||||
names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE)
|
||||
ifaces = sorted({n.split('@')[0] for n in names} - {'lo'})
|
||||
return ifaces
|
||||
|
||||
|
||||
def _iface_status(iface):
|
||||
"""Return link state for iface by reading /sys/class/net/<iface>/operstate.
|
||||
Returns INVALID if the interface does not exist, otherwise UP/DOWN/UNKNOWN/etc."""
|
||||
if not iface:
|
||||
return 'INVALID'
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '', iface)
|
||||
if not safe:
|
||||
return 'INVALID'
|
||||
try:
|
||||
with open(f'/sys/class/net/{safe}/operstate') as f:
|
||||
state = f.read().strip().upper()
|
||||
return state if state else 'UP'
|
||||
except OSError:
|
||||
return 'INVALID'
|
||||
|
||||
|
||||
def _resolve_iface(vlan, core):
|
||||
"""Compute interface name from is_vpn + vlan_id + general.lan_interface."""
|
||||
if vlan.get('is_vpn'):
|
||||
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
|
||||
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
|
||||
return f'wg{idx}'
|
||||
lan = core.get('general', {}).get('lan_interface', 'eth0')
|
||||
vid = vlan.get('vlan_id', 1)
|
||||
return lan if vid == 1 else f'{lan}.{vid}'
|
||||
|
||||
|
||||
# -- Live data loaders ---------------------------------------------------------
|
||||
|
||||
def _live_dhcp_leases():
|
||||
|
|
@ -91,11 +131,12 @@ def _live_dhcp_leases():
|
|||
def _vlan_name_for_ip(ip):
|
||||
import ipaddress
|
||||
for vlan in _load_core().get('vlans', []):
|
||||
subnet = vlan.get('dhcp', {}).get('subnet', '')
|
||||
subnet = vlan.get('subnet', '')
|
||||
mask = vlan.get('subnet_mask', 24)
|
||||
if not subnet:
|
||||
continue
|
||||
try:
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', strict=False):
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False):
|
||||
return vlan.get('name', '-')
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -139,6 +180,15 @@ def _config_datasource(name):
|
|||
core = _load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
|
||||
if name == 'interfaces':
|
||||
gen = core.get('general', {})
|
||||
wan = gen.get('wan_interface', '')
|
||||
lan = gen.get('lan_interface', '')
|
||||
return [
|
||||
{'iface_type': 'WAN', 'interface': wan, 'status': _iface_status(wan)},
|
||||
{'iface_type': 'LAN', 'interface': lan, 'status': _iface_status(lan)},
|
||||
]
|
||||
|
||||
if name == 'banned_ips':
|
||||
return core.get('banned_ips', [])
|
||||
|
||||
|
|
@ -161,10 +211,11 @@ def _config_datasource(name):
|
|||
return rows
|
||||
|
||||
if name == 'vlans':
|
||||
core = _load_core()
|
||||
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', '')
|
||||
for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)):
|
||||
row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
|
||||
row['interface'] = _resolve_iface(v, core)
|
||||
row['use_blocklists'] = json.dumps(v.get('use_blocklists', []))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
|
@ -208,6 +259,18 @@ def _config_datasource(name):
|
|||
rows.append(row)
|
||||
return rows
|
||||
|
||||
if name == 'vpn_peers':
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), None)
|
||||
if not wg_vlan:
|
||||
return []
|
||||
rows = []
|
||||
for peer in wg_vlan.get('peers', []):
|
||||
row = dict(peer)
|
||||
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
|
||||
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
|
@ -369,7 +432,22 @@ def collect_tokens():
|
|||
dns = core.get('upstream_dns', {})
|
||||
vlans = core.get('vlans', [])
|
||||
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
|
||||
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
|
||||
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
|
||||
|
||||
sys_ifaces = _get_system_interfaces()
|
||||
# Always include currently-configured values so dropdowns are never blank.
|
||||
for configured in [gen.get('wan_interface', ''), gen.get('lan_interface', '')]:
|
||||
if configured and configured not in sys_ifaces:
|
||||
sys_ifaces.append(configured)
|
||||
sys_ifaces.sort()
|
||||
tokens['NETWORK_INTERFACE_OPTIONS'] = json.dumps(
|
||||
[{'value': i, 'label': i} for i in sys_ifaces]
|
||||
)
|
||||
tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps(
|
||||
[{'value': i, 'label': f'{i} — {_iface_status(i).title()}'} for i in sys_ifaces]
|
||||
)
|
||||
|
||||
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
|
||||
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
|
||||
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
|
||||
|
|
@ -380,7 +458,7 @@ def collect_tokens():
|
|||
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]
|
||||
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))
|
||||
|
|
@ -392,6 +470,10 @@ def collect_tokens():
|
|||
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
|
||||
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
|
||||
|
||||
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
|
||||
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans])
|
||||
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
|
||||
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
|
||||
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
|
||||
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
|
||||
|
||||
|
|
@ -405,13 +487,28 @@ def collect_tokens():
|
|||
{'value': 'duckdns', 'label': 'DuckDNS'},
|
||||
])
|
||||
|
||||
vpn = _vpn_info()
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
|
||||
vpn = wg_vlan.get('vpn_information', {})
|
||||
overrides = vpn.get('explicit_overrides', {})
|
||||
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
|
||||
tokens['VPN_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', ''))
|
||||
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
|
||||
tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', ''))
|
||||
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
|
||||
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
|
||||
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
|
||||
# Compute gateway from server_identities (lowest last-octet), fallback to first subnet host
|
||||
try:
|
||||
import ipaddress as _ipaddress
|
||||
ident_ips = [s['ip'] for s in wg_vlan.get('server_identities', []) if s.get('ip')]
|
||||
if ident_ips:
|
||||
default_gw = str(min((_ipaddress.IPv4Address(ip) for ip in ident_ips),
|
||||
key=lambda x: x.packed[-1]))
|
||||
else:
|
||||
wg_net = _ipaddress.IPv4Network(
|
||||
f"{wg_vlan['subnet']}/{wg_vlan['subnet_mask']}", strict=False)
|
||||
default_gw = str(next(wg_net.hosts()))
|
||||
tokens['VPN_GATEWAY'] = overrides.get('gateway') or default_gw
|
||||
except Exception:
|
||||
tokens['VPN_GATEWAY'] = ''
|
||||
|
||||
ip_str, sub_str, next_interval = _public_ip_info(ddns)
|
||||
tokens['STAT_PUBLIC_IP'] = ip_str
|
||||
|
|
@ -441,6 +538,28 @@ def collect_tokens():
|
|||
blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]
|
||||
)
|
||||
|
||||
tokens['PROTOCOL_OPTIONS'] = json.dumps([
|
||||
{'value': 'tcp', 'label': 'TCP'},
|
||||
{'value': 'udp', 'label': 'UDP'},
|
||||
{'value': 'both', 'label': 'TCP/UDP'},
|
||||
])
|
||||
|
||||
tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([
|
||||
{'value': 'hosts', 'label': 'hosts (hosts file format)'},
|
||||
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
|
||||
])
|
||||
|
||||
tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([
|
||||
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
|
||||
for bl in core.get('blocklists', [])
|
||||
])
|
||||
|
||||
tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([
|
||||
{'value': 'viewer', 'label': 'Viewer (read-only access to live data)'},
|
||||
{'value': 'administrator', 'label': 'Administrator (can modify configuration)'},
|
||||
{'value': 'manager', 'label': 'Manager (full access including account management)'},
|
||||
])
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
|
|
@ -511,6 +630,9 @@ def _render_item(item, tokens, inherited_req=None):
|
|||
if t == 'spacer':
|
||||
return '<div class="spacer"></div>'
|
||||
|
||||
if t == 'divider':
|
||||
return '<hr class="divider">'
|
||||
|
||||
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
|
||||
cls_map = {
|
||||
'button_primary': 'btn-primary',
|
||||
|
|
@ -522,11 +644,12 @@ 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 = e(apply_tokens(item.get('action', '#'), tokens))
|
||||
text = e(apply_tokens(item.get('text', ''), tokens))
|
||||
action = e(apply_tokens(item.get('action', '#'), tokens))
|
||||
disabled = ' disabled' if item.get('disabled') else ''
|
||||
if item.get('method', '').lower() == 'post':
|
||||
return (f'<form method="post" action="{action}" style="display:inline">'
|
||||
f'<button type="submit" class="btn {e(cls)}">{text}</button></form>')
|
||||
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>')
|
||||
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
|
||||
|
||||
if t == 'button_cancel':
|
||||
|
|
@ -616,13 +739,46 @@ def _render_item(item, tokens, inherited_req=None):
|
|||
if t == 'field':
|
||||
return _render_field(item, tokens)
|
||||
|
||||
if t == 'field_row':
|
||||
inner = render_items(item.get('items', []), tokens, req)
|
||||
cols = item.get('cols', 2)
|
||||
return f'<div class="form-row-{cols}">{inner}</div>'
|
||||
|
||||
if t == 'subnet_row':
|
||||
subnet_name = e(item.get('subnet_name', 'subnet'))
|
||||
prefix_name = e(item.get('prefix_name', 'subnet_mask'))
|
||||
subnet_val = apply_tokens(item.get('subnet_value', ''), tokens)
|
||||
prefix_raw = apply_tokens(item.get('prefix_value', '24'), tokens)
|
||||
subnet_ph = e(apply_tokens(item.get('subnet_placeholder', ''), tokens))
|
||||
show_derived = item.get('show_derived_vlan_id', False)
|
||||
try:
|
||||
pf = max(1, min(30, int(prefix_raw)))
|
||||
except (ValueError, TypeError):
|
||||
pf = 24
|
||||
dotted = _prefix_to_dotted(pf)
|
||||
|
||||
return (
|
||||
f'<div class="form-group">'
|
||||
f'<label class="form-label">Subnet</label>'
|
||||
f'<div class="subnet-row-wrap">'
|
||||
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input">'
|
||||
f'<span class="subnet-sep">/</span>'
|
||||
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input">'
|
||||
f'<span class="subnet-dotted">{e(dotted)}</span>'
|
||||
f'</div>'
|
||||
f'<p class="form-hint field-dyn-hint" style="display:none"></p>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
if t == 'editable_list':
|
||||
return _render_editable_list(item, tokens)
|
||||
|
||||
if t == 'select':
|
||||
name = e(item.get('name', ''))
|
||||
options = apply_tokens(item.get('options', ''), tokens)
|
||||
return f'<select name="{name}" class="form-select">{options}</select>'
|
||||
name = e(item.get('name', ''))
|
||||
options = apply_tokens(item.get('options', ''), tokens)
|
||||
filter_col = item.get('filter_col', '')
|
||||
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
|
||||
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
|
||||
|
||||
if t == 'button_row':
|
||||
inner = render_items(item.get('items', []), tokens, req)
|
||||
|
|
@ -642,12 +798,22 @@ def _render_field(item, tokens):
|
|||
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
|
||||
hint = e(apply_tokens(item.get('hint', ''), tokens))
|
||||
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
|
||||
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
|
||||
readonly = ' readonly' if item.get('readonly') else ''
|
||||
|
||||
if input_type == 'hidden':
|
||||
return f'<input type="hidden" name="{name}" value="{e(value)}">'
|
||||
|
||||
if input_type == 'checkbox':
|
||||
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
|
||||
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
|
||||
cb_label = item.get('checkbox_label')
|
||||
if cb_label:
|
||||
return (f'<div class="form-group">'
|
||||
f'<label class="form-label">{label}</label>'
|
||||
f'<label class="form-checkbox-row">'
|
||||
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox">'
|
||||
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
|
||||
f'</label>{hint_html}</div>')
|
||||
return (f'<div class="form-group">'
|
||||
f'<label class="form-label">'
|
||||
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"> {label}'
|
||||
|
|
@ -689,7 +855,7 @@ def _render_field(item, tokens):
|
|||
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'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr} class="form-input{extra_cls}"{readonly}>'
|
||||
f'{hint_html}</div>')
|
||||
|
||||
if input_type == 'textarea':
|
||||
|
|
@ -699,9 +865,10 @@ def _render_field(item, tokens):
|
|||
f' class="form-input">{e(value)}</textarea>'
|
||||
f'{hint_html}</div>')
|
||||
|
||||
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if item.get('readonly') else ''
|
||||
return (f'<div class="form-group"><label class="form-label">{label}</label>'
|
||||
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
|
||||
f' placeholder="{placeholder}" class="form-input">{hint_html}</div>')
|
||||
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}>{hint_html}{dyn_hint}</div>')
|
||||
|
||||
|
||||
def _render_editable_list(item, tokens):
|
||||
|
|
@ -784,6 +951,10 @@ def _render_table(item, tokens, inherited_req=None):
|
|||
action = e(apply_tokens(ra.get('action', '#'), tokens))
|
||||
method = ra.get('method', 'post').lower()
|
||||
if method == 'post':
|
||||
disable_if = ra.get('disable_if')
|
||||
if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'):
|
||||
btns += f'<button type="button" class="btn {cls}" disabled>{text}</button>'
|
||||
continue
|
||||
btns += (f'<form method="post" action="{action}" style="display:inline">'
|
||||
f'<input type="hidden" name="row_index" value="{idx}">'
|
||||
f'<input type="hidden" name="config_hash" value="{e(hash_val)}">'
|
||||
|
|
@ -859,6 +1030,18 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None,
|
|||
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>'
|
||||
|
||||
if render_fn == 'interface_status':
|
||||
v = value.upper()
|
||||
if v == 'INVALID':
|
||||
inner = '<span class="badge badge-danger">Invalid</span>'
|
||||
elif v == 'UP':
|
||||
inner = '<span class="badge badge-enabled">Up</span>'
|
||||
elif v == 'DOWN':
|
||||
inner = '<span class="badge badge-warning">Down</span>'
|
||||
else:
|
||||
inner = f'<span class="badge badge-disabled">{e(value.title())}</span>'
|
||||
return f'{td_open}{inner}</td>'
|
||||
|
||||
return f'{td_open}{e(value)}</td>'
|
||||
|
||||
|
||||
|
|
@ -882,7 +1065,12 @@ def render_layout(view_id, content_html, tokens):
|
|||
navbar_html = _render_navbar(view_id, level, tokens)
|
||||
footer_html = '<footer class="footer">Router Dashboard</footer>'
|
||||
|
||||
page_hash = core_hash()
|
||||
page_hash = core_hash()
|
||||
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
|
||||
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
|
||||
existing_ids = tokens.get('EXISTING_VLAN_IDS_JSON', '[]')
|
||||
existing_names = tokens.get('EXISTING_VLAN_NAMES_JSON', '[]')
|
||||
existing_interfaces = tokens.get('EXISTING_VLAN_INTERFACES_JSON', '[]')
|
||||
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
||||
f' <meta charset="UTF-8">\n'
|
||||
f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||
|
|
@ -893,7 +1081,7 @@ def render_layout(view_id, content_html, tokens):
|
|||
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>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var EXISTING_VLAN_IDS={existing_ids};var EXISTING_VLAN_NAMES={existing_names};var EXISTING_VLAN_INTERFACES={existing_interfaces};</script>\n'
|
||||
f'<script>{_inline_js()}</script>\n'
|
||||
f'</body>\n</html>')
|
||||
|
||||
|
|
@ -956,6 +1144,156 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
|
|||
|
||||
def _inline_js():
|
||||
return r"""
|
||||
function prefixToDotted(n) {
|
||||
if (n < 1 || n > 30) return '';
|
||||
var mask = ((0xFFFFFFFF << (32 - n)) >>> 0);
|
||||
return [(mask >>> 24) & 0xFF, (mask >>> 16) & 0xFF, (mask >>> 8) & 0xFF, mask & 0xFF].join('.');
|
||||
}
|
||||
|
||||
function deriveVlanId(subnet, prefix) {
|
||||
var parts = subnet.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
var octets = parts.map(function(p) { return parseInt(p, 10); });
|
||||
if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null;
|
||||
var byteIdx = Math.floor((prefix - 1) / 8);
|
||||
var id = octets[byteIdx];
|
||||
return (id >= 0 && id <= 4094) ? id : null;
|
||||
}
|
||||
|
||||
function classifySubnet(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^0-9.]/.test(s)) return 'invalid_char';
|
||||
if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct';
|
||||
var parts = s.split('.');
|
||||
if (parts.length > 4) return 'too_many';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i];
|
||||
if (!p) continue;
|
||||
var n = parseInt(p, 10);
|
||||
if (isNaN(n) || n > 255) return 'range';
|
||||
}
|
||||
if (parts.length < 4 || parts[3] === '') return 'incomplete';
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function setFieldHint(input, message, state) {
|
||||
// state: 'error' | 'warning' | 'ok'
|
||||
var fg = input.closest('.form-group');
|
||||
if (fg) {
|
||||
var hint = fg.querySelector('.field-dyn-hint');
|
||||
if (hint) {
|
||||
hint.textContent = message;
|
||||
hint.style.display = message ? '' : 'none';
|
||||
hint.style.color = (state === 'error') ? 'var(--danger)' : 'var(--text-muted)';
|
||||
}
|
||||
}
|
||||
input.classList.remove('field-invalid', 'field-warning');
|
||||
if (state === 'error' && message) input.classList.add('field-invalid');
|
||||
else if (state === 'warning') input.classList.add('field-warning');
|
||||
}
|
||||
|
||||
function updateAddVlanForm(form) {
|
||||
var nameInp = form.querySelector('input[name="name"]');
|
||||
var subnetInp = form.querySelector('input[name="subnet"]');
|
||||
var prefixInp = form.querySelector('input.subnet-prefix-input');
|
||||
var vpnChk = form.querySelector('input[name="is_vpn"]');
|
||||
var ifacePrev = form.querySelector('.vlan-iface-preview');
|
||||
var derivedPrev = form.querySelector('.vlan-derived-id-preview');
|
||||
var submitBtn = form.querySelector('.add-vlan-btn');
|
||||
if (!subnetInp || !prefixInp) return;
|
||||
|
||||
var subnet = subnetInp.value.trim();
|
||||
var prefix = parseInt(prefixInp.value, 10);
|
||||
var isVpn = vpnChk && vpnChk.checked;
|
||||
var lan = typeof LAN_IFACE !== 'undefined' ? LAN_IFACE : 'eth0';
|
||||
var sClass = classifySubnet(subnet);
|
||||
var id = (sClass === 'complete') ? deriveVlanId(subnet, prefix) : null;
|
||||
|
||||
// Derived VLAN ID preview
|
||||
if (derivedPrev) derivedPrev.value = (id !== null) ? String(id) : '';
|
||||
|
||||
// Interface preview
|
||||
var ifaceVal = '';
|
||||
if (isVpn) {
|
||||
ifaceVal = 'wg' + (typeof VPN_VLAN_COUNT !== 'undefined' ? VPN_VLAN_COUNT : 0);
|
||||
} else if (id !== null) {
|
||||
ifaceVal = (id === 1) ? lan : lan + '.' + id;
|
||||
}
|
||||
if (ifacePrev) ifacePrev.value = ifaceVal;
|
||||
|
||||
// Subnet sub-text + colour
|
||||
var subnetMsg = '', subnetState = 'ok', subnetOk = false;
|
||||
if (sClass === 'empty' || sClass === 'incomplete') {
|
||||
subnetState = 'warning';
|
||||
} else if (sClass === 'invalid_char' || sClass === 'invalid_struct' || sClass === 'too_many') {
|
||||
subnetMsg = 'Invalid'; subnetState = 'error';
|
||||
} else if (sClass === 'range') {
|
||||
subnetMsg = 'Quartet out of range'; subnetState = 'error';
|
||||
} else {
|
||||
if (id === 0) {
|
||||
subnetMsg = 'Reserved'; subnetState = 'warning';
|
||||
} 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) {
|
||||
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);
|
||||
|
|
@ -1019,6 +1357,19 @@ document.addEventListener('click', function(e) {
|
|||
var checked = (val === true || val === 'true' || val === 1 || val === '1');
|
||||
td.innerHTML = '<input type="checkbox" name="' + field + '"' +
|
||||
(checked ? ' checked' : '') + ' class="inline-edit-checkbox">';
|
||||
} else if (inputType === 'checkbox_multi') {
|
||||
var opts = fDef.options || [];
|
||||
var checked = [];
|
||||
try { var parsed = JSON.parse(val); if (Array.isArray(parsed)) checked = parsed; } catch(ex) {}
|
||||
var cbHtml = '<div class="checkbox-multi-group">';
|
||||
opts.forEach(function(o) {
|
||||
var isChecked = checked.indexOf(o.value) !== -1;
|
||||
cbHtml += '<label class="checkbox-multi-item">' +
|
||||
'<input type="checkbox" name="' + field + '" value="' + esc(o.value) + '"' +
|
||||
(isChecked ? ' checked' : '') + ' class="inline-edit-checkbox-multi"> ' + esc(o.label) + '</label>';
|
||||
});
|
||||
cbHtml += '</div>';
|
||||
td.innerHTML = cbHtml;
|
||||
} else if (inputType === 'select') {
|
||||
var opts = fDef.options || [];
|
||||
var selHtml = '<select name="' + field + '" class="form-select inline-edit-select">';
|
||||
|
|
@ -1028,6 +1379,11 @@ document.addEventListener('click', function(e) {
|
|||
});
|
||||
selHtml += '</select>';
|
||||
td.innerHTML = selHtml;
|
||||
} else if (inputType === 'number') {
|
||||
var minAttr = fDef.min !== undefined ? ' min="' + esc(String(fDef.min)) + '"' : '';
|
||||
var maxAttr = fDef.max !== undefined ? ' max="' + esc(String(fDef.max)) + '"' : '';
|
||||
td.innerHTML = '<input type="number" name="' + field + '" value="' + esc(String(val)) +
|
||||
'"' + minAttr + maxAttr + ' class="form-input inline-edit-input">';
|
||||
} else if (inputType === 'textarea') {
|
||||
var textVal;
|
||||
try { var arr = JSON.parse(val); textVal = Array.isArray(arr) ? arr.join('\n') : String(val||''); }
|
||||
|
|
@ -1075,7 +1431,11 @@ document.addEventListener('click', function(e) {
|
|||
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');
|
||||
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);
|
||||
}
|
||||
|
|
@ -1093,6 +1453,26 @@ document.addEventListener('click', function(e) {
|
|||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue