UI improvement

This commit is contained in:
Matthew Grotke 2026-05-18 14:38:23 -04:00
parent 575edc836d
commit 9a272ee959
16 changed files with 2477 additions and 1604 deletions

View file

@ -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();