Add WiFi SSID/password support and replace DHCP/Static toggle with radio buttons
Backend: wireless interface detection, wpa_supplicant config writing, WiFi scan endpoint via iw/iwlist. Frontend: segmented radio selector for DHCP/Static (fixes text overflow behind right panel), SSID/password fields for wireless interfaces with network scan dropdown, Apply handler sends WiFi credentials. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a73b515258
commit
06e958f488
3 changed files with 379 additions and 39 deletions
|
|
@ -37,6 +37,9 @@ _IP_BIN = _find_bin("ip") or "ip"
|
|||
_INTERFACES_FILE = Path("/etc/network/interfaces")
|
||||
_INTERFACES_DIR = Path("/etc/network/interfaces.d")
|
||||
_RESOLV_CONF = Path("/etc/resolv.conf")
|
||||
_WPA_SUPPLICANT_DIR = Path("/etc/wpa_supplicant")
|
||||
_IW_BIN = _find_bin("iw")
|
||||
_WPA_CLI_BIN = _find_bin("wpa_cli")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -438,6 +441,9 @@ async def _parse_ip_addr() -> list[dict]:
|
|||
if not ai.get("local", "").startswith("fe80"):
|
||||
ipv6 = ai.get("local", "")
|
||||
|
||||
# Detect wireless interfaces
|
||||
is_wireless = Path(f"/sys/class/net/{name}/wireless").exists()
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"state": state,
|
||||
|
|
@ -445,6 +451,7 @@ async def _parse_ip_addr() -> list[dict]:
|
|||
"mac": mac,
|
||||
"ipv4": ipv4,
|
||||
"ipv6": ipv6,
|
||||
"wireless": is_wireless,
|
||||
})
|
||||
|
||||
# Get default gateway
|
||||
|
|
@ -492,6 +499,7 @@ async def _parse_ip_addr_text() -> list[dict]:
|
|||
"ipv4": "",
|
||||
"ipv6": "",
|
||||
"gateway": "",
|
||||
"wireless": Path(f"/sys/class/net/{name}/wireless").exists(),
|
||||
}
|
||||
continue
|
||||
|
||||
|
|
@ -597,18 +605,23 @@ def _parse_interfaces_file() -> dict[str, dict]:
|
|||
|
||||
|
||||
def _write_interface_config(iface: str, method: str, address: str = "",
|
||||
gateway: str = "", dns: list[str] = None):
|
||||
gateway: str = "", dns: list[str] = None,
|
||||
ssid: str = "", wifi_password: str = ""):
|
||||
"""Write or update an interface block in /etc/network/interfaces.d/{iface}.
|
||||
|
||||
Uses interfaces.d to avoid clobbering the main file.
|
||||
For wireless interfaces, also writes wpa_supplicant config.
|
||||
"""
|
||||
_INTERFACES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
is_wireless = Path(f"/sys/class/net/{iface}/wireless").exists()
|
||||
|
||||
if method == "dhcp":
|
||||
content = f"""# Managed by Atlus
|
||||
auto {iface}
|
||||
iface {iface} inet dhcp
|
||||
"""
|
||||
lines = [
|
||||
f"# Managed by Atlus",
|
||||
f"auto {iface}",
|
||||
f"iface {iface} inet dhcp",
|
||||
]
|
||||
else:
|
||||
# Parse CIDR to address + netmask
|
||||
try:
|
||||
|
|
@ -629,11 +642,18 @@ iface {iface} inet dhcp
|
|||
lines.append(f" gateway {gateway}")
|
||||
if dns:
|
||||
lines.append(f" dns-nameservers {' '.join(dns)}")
|
||||
content = "\n".join(lines) + "\n"
|
||||
|
||||
# Add WiFi config if wireless and SSID provided
|
||||
if is_wireless and ssid:
|
||||
wpa_conf = _WPA_SUPPLICANT_DIR / f"{iface}.conf"
|
||||
_write_wpa_supplicant(iface, ssid, wifi_password)
|
||||
lines.append(f" wpa-ssid {ssid}")
|
||||
lines.append(f" wpa-conf {wpa_conf}")
|
||||
|
||||
content = "\n".join(lines) + "\n"
|
||||
config_file = _INTERFACES_DIR / iface
|
||||
config_file.write_text(content)
|
||||
log.info("Wrote interface config for %s: method=%s", iface, method)
|
||||
log.info("Wrote interface config for %s: method=%s ssid=%s", iface, method, ssid or "(none)")
|
||||
|
||||
# Also update resolv.conf for static DNS
|
||||
if method == "static" and dns:
|
||||
|
|
@ -667,6 +687,7 @@ async def list_interfaces(_user: str = Depends(get_current_user)):
|
|||
iface["config_netmask"] = cfg.get("netmask", "")
|
||||
iface["config_gateway"] = cfg.get("gateway", "")
|
||||
iface["config_dns"] = cfg.get("dns-nameservers", "")
|
||||
iface["config_ssid"] = cfg.get("wpa-ssid", "")
|
||||
|
||||
# Get DNS from resolv.conf
|
||||
dns_servers = []
|
||||
|
|
@ -691,6 +712,8 @@ class InterfaceConfig(BaseModel):
|
|||
address: Optional[str] = None # CIDR e.g. "192.168.1.100/24"
|
||||
gateway: Optional[str] = None
|
||||
dns: Optional[list[str]] = None
|
||||
ssid: Optional[str] = None # WiFi SSID
|
||||
wifi_password: Optional[str] = None # WiFi password (WPA/WPA2)
|
||||
|
||||
|
||||
@router.put("/interfaces/{name}/config")
|
||||
|
|
@ -717,6 +740,8 @@ async def configure_interface(
|
|||
address=cfg.address or "",
|
||||
gateway=cfg.gateway or "",
|
||||
dns=cfg.dns,
|
||||
ssid=cfg.ssid or "",
|
||||
wifi_password=cfg.wifi_password or "",
|
||||
)
|
||||
|
||||
# Apply: bring interface down then up
|
||||
|
|
@ -768,6 +793,161 @@ async def _apply_with_ip(name: str, cfg: InterfaceConfig):
|
|||
log.warning("No DHCP client found — interface %s brought up without DHCP", name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WiFi support (fallback — no NetworkManager)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_wpa_supplicant(iface: str, ssid: str, password: str = ""):
|
||||
"""Write a wpa_supplicant config file for a wireless interface."""
|
||||
_WPA_SUPPLICANT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
conf_file = _WPA_SUPPLICANT_DIR / f"{iface}.conf"
|
||||
|
||||
if password:
|
||||
# WPA/WPA2 PSK
|
||||
content = f"""# Managed by Atlus
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=US
|
||||
|
||||
network={{
|
||||
ssid="{ssid}"
|
||||
psk="{password}"
|
||||
key_mgmt=WPA-PSK
|
||||
}}
|
||||
"""
|
||||
else:
|
||||
# Open network
|
||||
content = f"""# Managed by Atlus
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=US
|
||||
|
||||
network={{
|
||||
ssid="{ssid}"
|
||||
key_mgmt=NONE
|
||||
}}
|
||||
"""
|
||||
conf_file.write_text(content)
|
||||
conf_file.chmod(0o600)
|
||||
log.info("Wrote wpa_supplicant config for %s: ssid=%s", iface, ssid)
|
||||
|
||||
|
||||
@router.get("/wifi/scan/{name}")
|
||||
async def scan_wifi(name: str, _user: str = Depends(get_current_user)):
|
||||
"""Scan for WiFi networks using iw or iwlist (no NetworkManager required)."""
|
||||
_validate_iface_name(name)
|
||||
|
||||
# Check if interface is wireless
|
||||
if not Path(f"/sys/class/net/{name}/wireless").exists():
|
||||
raise HTTPException(400, f"{name} is not a wireless interface")
|
||||
|
||||
networks = []
|
||||
|
||||
# Try iw first
|
||||
if _IW_BIN:
|
||||
rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15)
|
||||
if rc == 0:
|
||||
networks = _parse_iw_scan(stdout)
|
||||
elif rc != 0:
|
||||
# May need to bring interface up first
|
||||
await _run_cmd(_IP_BIN, "link", "set", name, "up")
|
||||
await asyncio.sleep(1)
|
||||
rc, stdout, _ = await _run_cmd(_IW_BIN, "dev", name, "scan", timeout=15)
|
||||
if rc == 0:
|
||||
networks = _parse_iw_scan(stdout)
|
||||
|
||||
# Fallback: iwlist
|
||||
if not networks:
|
||||
iwlist = _find_bin("iwlist")
|
||||
if iwlist:
|
||||
rc, stdout, _ = await _run_cmd(iwlist, name, "scan", timeout=15)
|
||||
if rc == 0:
|
||||
networks = _parse_iwlist_scan(stdout)
|
||||
|
||||
return {"networks": networks}
|
||||
|
||||
|
||||
def _parse_iw_scan(output: str) -> list[dict]:
|
||||
"""Parse `iw dev wlan0 scan` output into network list."""
|
||||
networks = []
|
||||
current = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("BSS "):
|
||||
if current and current.get("ssid"):
|
||||
networks.append(current)
|
||||
bssid = re.match(r"BSS\s+(\S+)", line)
|
||||
current = {"bssid": bssid.group(1) if bssid else "", "ssid": "", "signal": 0, "security": "Open"}
|
||||
elif current is None:
|
||||
continue
|
||||
elif "SSID:" in line:
|
||||
m = re.search(r"SSID:\s*(.*)", line)
|
||||
if m:
|
||||
current["ssid"] = m.group(1).strip()
|
||||
elif "signal:" in line:
|
||||
m = re.search(r"signal:\s*(-?\d+)", line)
|
||||
if m:
|
||||
current["signal"] = int(m.group(1))
|
||||
elif "WPA" in line or "RSN" in line:
|
||||
current["security"] = "WPA2" if "RSN" in line else "WPA"
|
||||
elif "WEP" in line:
|
||||
current["security"] = "WEP"
|
||||
if current and current.get("ssid"):
|
||||
networks.append(current)
|
||||
|
||||
# Sort by signal strength (strongest first)
|
||||
networks.sort(key=lambda n: n.get("signal", -100), reverse=True)
|
||||
# Deduplicate by SSID
|
||||
seen = set()
|
||||
unique = []
|
||||
for n in networks:
|
||||
if n["ssid"] not in seen:
|
||||
seen.add(n["ssid"])
|
||||
unique.append(n)
|
||||
return unique[:30]
|
||||
|
||||
|
||||
def _parse_iwlist_scan(output: str) -> list[dict]:
|
||||
"""Parse `iwlist wlan0 scan` output into network list."""
|
||||
networks = []
|
||||
current = None
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if "Cell " in line and "Address:" in line:
|
||||
if current and current.get("ssid"):
|
||||
networks.append(current)
|
||||
m = re.search(r"Address:\s*(\S+)", line)
|
||||
current = {"bssid": m.group(1) if m else "", "ssid": "", "signal": 0, "security": "Open"}
|
||||
elif current is None:
|
||||
continue
|
||||
elif line.startswith("ESSID:"):
|
||||
m = re.search(r'ESSID:"(.+)"', line)
|
||||
if m:
|
||||
current["ssid"] = m.group(1)
|
||||
elif "Signal level=" in line:
|
||||
m = re.search(r"Signal level[=:]?\s*(-?\d+)", line)
|
||||
if m:
|
||||
current["signal"] = int(m.group(1))
|
||||
elif "Encryption key:on" in line:
|
||||
if current.get("security") == "Open":
|
||||
current["security"] = "WEP"
|
||||
elif "WPA2" in line or "IEEE 802.11i" in line:
|
||||
current["security"] = "WPA2"
|
||||
elif "WPA" in line:
|
||||
if current.get("security") != "WPA2":
|
||||
current["security"] = "WPA"
|
||||
if current and current.get("ssid"):
|
||||
networks.append(current)
|
||||
|
||||
networks.sort(key=lambda n: n.get("signal", -100), reverse=True)
|
||||
seen = set()
|
||||
unique = []
|
||||
for n in networks:
|
||||
if n["ssid"] not in seen:
|
||||
seen.add(n["ssid"])
|
||||
unique.append(n)
|
||||
return unique[:30]
|
||||
|
||||
|
||||
@router.post("/interfaces/{name}/up")
|
||||
async def interface_up(name: str, _user: str = Depends(get_current_user)):
|
||||
"""Bring an interface up."""
|
||||
|
|
|
|||
|
|
@ -203,14 +203,89 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
.settings-toggle .toggle-label {
|
||||
position: absolute;
|
||||
left: 60px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/* Network method radio buttons */
|
||||
.net-method-radios {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-structural);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.net-radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 6px 14px;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.net-radio-label input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.net-radio-label:has(input:checked) {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.net-radio-label:hover:not(:has(input:checked)) {
|
||||
background: var(--accent-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* WiFi fields for wireless interfaces */
|
||||
.net-wifi-fields {
|
||||
border-top: 1px dashed var(--border-structural);
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.wifi-scan-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 4px 0;
|
||||
border: 1px solid var(--border-structural);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.wifi-scan-results.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wifi-scan-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-structural);
|
||||
}
|
||||
|
||||
.wifi-scan-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.wifi-scan-item:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.wifi-scan-ssid {
|
||||
flex: 1;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -333,12 +333,34 @@
|
|||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-row-label">IP Configuration</div>
|
||||
<div class="settings-row-desc">Toggle between DHCP and Static IP</div>
|
||||
</div>
|
||||
<button class="settings-toggle ${isStatic ? 'on' : ''}" data-iface-dhcp="${iface.name}">
|
||||
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span>
|
||||
</button>
|
||||
<div class="net-method-radios" data-iface-method="${iface.name}">
|
||||
<label class="net-radio-label">
|
||||
<input type="radio" name="ifMethod-${iface.name}" value="dhcp" ${!isStatic ? 'checked' : ''}>
|
||||
<span>DHCP</span>
|
||||
</label>
|
||||
<label class="net-radio-label">
|
||||
<input type="radio" name="ifMethod-${iface.name}" value="static" ${isStatic ? 'checked' : ''}>
|
||||
<span>Static</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
${iface.wireless ? `
|
||||
<div class="net-wifi-fields">
|
||||
<div class="settings-row">
|
||||
<div><div class="settings-row-label">WiFi SSID</div></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input class="settings-input" id="ifSsid-${iface.name}" value="${iface.config_ssid || ''}" placeholder="Network name">
|
||||
<button class="settings-btn secondary" style="height:40px;font-size:11px;padding:0 10px;white-space:nowrap;" data-wifi-scan="${iface.name}">Scan</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wifiScanResults-${iface.name}" class="wifi-scan-results hidden"></div>
|
||||
<div class="settings-row">
|
||||
<div><div class="settings-row-label">WiFi Password</div></div>
|
||||
<input class="settings-input" id="ifWifiPass-${iface.name}" type="password" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}">
|
||||
<div class="settings-row">
|
||||
<div><div class="settings-row-label">IP Address</div><div class="settings-row-desc">CIDR notation (e.g. 192.168.1.100/24)</div></div>
|
||||
|
|
@ -398,15 +420,59 @@
|
|||
});
|
||||
});
|
||||
|
||||
// DHCP/Static toggle
|
||||
contentEl.querySelectorAll('[data-iface-dhcp]').forEach(toggle => {
|
||||
const name = toggle.dataset.ifaceDhcp;
|
||||
// DHCP/Static radio buttons
|
||||
contentEl.querySelectorAll('[data-iface-method]').forEach(group => {
|
||||
const name = group.dataset.ifaceMethod;
|
||||
const staticFields = contentEl.querySelector(`#staticFields-${name}`);
|
||||
toggle.addEventListener('click', () => {
|
||||
toggle.classList.toggle('on');
|
||||
const isNowStatic = toggle.classList.contains('on');
|
||||
toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP';
|
||||
staticFields.classList.toggle('hidden', !isNowStatic);
|
||||
group.querySelectorAll('input[type="radio"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
staticFields.classList.toggle('hidden', radio.value !== 'static');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// WiFi scan buttons
|
||||
contentEl.querySelectorAll('[data-wifi-scan]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = btn.dataset.wifiScan;
|
||||
const resultsEl = contentEl.querySelector(`#wifiScanResults-${name}`);
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Scanning…';
|
||||
resultsEl.classList.remove('hidden');
|
||||
resultsEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Scanning…</div>';
|
||||
|
||||
try {
|
||||
const res = await Atlus.apiFetch(`/api/network/wifi/scan/${name}`);
|
||||
if (!res.ok) {
|
||||
resultsEl.innerHTML = '<div class="net-error">Failed to scan WiFi networks</div>';
|
||||
} else {
|
||||
const networks = await res.json();
|
||||
if (networks.length === 0) {
|
||||
resultsEl.innerHTML = '<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found</div>';
|
||||
} else {
|
||||
resultsEl.innerHTML = '';
|
||||
for (const net of networks) {
|
||||
const sig = _signalLabel(net.signal);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'wifi-scan-item';
|
||||
item.innerHTML = `
|
||||
<span class="wifi-scan-ssid">${net.ssid}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:11px;color:${sig.color};">${sig.text}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${net.security || 'Open'}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
contentEl.querySelector(`#ifSsid-${name}`).value = net.ssid;
|
||||
resultsEl.classList.add('hidden');
|
||||
});
|
||||
resultsEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
resultsEl.innerHTML = '<div class="net-error">Scan failed</div>';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Scan';
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -417,9 +483,10 @@
|
|||
const errEl = contentEl.querySelector(`#ifError-${name}`);
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const toggle = contentEl.querySelector(`[data-iface-dhcp="${name}"]`);
|
||||
const isStatic = toggle.classList.contains('on');
|
||||
const method = isStatic ? 'static' : 'dhcp';
|
||||
const methodGroup = contentEl.querySelector(`[data-iface-method="${name}"]`);
|
||||
const selectedRadio = methodGroup.querySelector('input[type="radio"]:checked');
|
||||
const method = selectedRadio ? selectedRadio.value : 'dhcp';
|
||||
const isStatic = method === 'static';
|
||||
const body = { method };
|
||||
|
||||
if (isStatic) {
|
||||
|
|
@ -436,6 +503,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Include WiFi credentials if this is a wireless interface
|
||||
const ssidInput = contentEl.querySelector(`#ifSsid-${name}`);
|
||||
const wifiPassInput = contentEl.querySelector(`#ifWifiPass-${name}`);
|
||||
if (ssidInput) {
|
||||
const ssid = ssidInput.value.trim();
|
||||
if (ssid) body.ssid = ssid;
|
||||
if (wifiPassInput) {
|
||||
const wifiPass = wifiPassInput.value;
|
||||
if (wifiPass) body.wifi_password = wifiPass;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
|
@ -496,11 +575,17 @@
|
|||
<div class="settings-row">
|
||||
<div>
|
||||
<div class="settings-row-label">IP Configuration</div>
|
||||
<div class="settings-row-desc">Toggle between DHCP and Static IP</div>
|
||||
</div>
|
||||
<button class="settings-toggle ${isStatic ? 'on' : ''}" id="dhcpToggle-${dev.device}">
|
||||
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span>
|
||||
</button>
|
||||
<div class="net-method-radios" id="methodRadios-${dev.device}">
|
||||
<label class="net-radio-label">
|
||||
<input type="radio" name="ethMethod-${dev.device}" value="dhcp" ${!isStatic ? 'checked' : ''}>
|
||||
<span>DHCP</span>
|
||||
</label>
|
||||
<label class="net-radio-label">
|
||||
<input type="radio" name="ethMethod-${dev.device}" value="static" ${isStatic ? 'checked' : ''}>
|
||||
<span>Static</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${dev.device}">
|
||||
<div class="settings-row">
|
||||
|
|
@ -526,14 +611,13 @@
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Toggle handler
|
||||
const toggle = groupEl.querySelector(`#dhcpToggle-${dev.device}`);
|
||||
// Radio handler
|
||||
const methodRadios = groupEl.querySelector(`#methodRadios-${dev.device}`);
|
||||
const staticFields = groupEl.querySelector(`#staticFields-${dev.device}`);
|
||||
toggle.addEventListener('click', () => {
|
||||
toggle.classList.toggle('on');
|
||||
const isNowStatic = toggle.classList.contains('on');
|
||||
toggle.querySelector('.toggle-label').textContent = isNowStatic ? 'Static' : 'DHCP';
|
||||
staticFields.classList.toggle('hidden', !isNowStatic);
|
||||
methodRadios.querySelectorAll('input[type="radio"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
staticFields.classList.toggle('hidden', radio.value !== 'static');
|
||||
});
|
||||
});
|
||||
|
||||
// Apply handler
|
||||
|
|
@ -541,7 +625,8 @@
|
|||
const errEl = groupEl.querySelector(`#ethError-${dev.device}`);
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const isNowStatic = toggle.classList.contains('on');
|
||||
const selectedRadio = methodRadios.querySelector('input[type="radio"]:checked');
|
||||
const isNowStatic = selectedRadio && selectedRadio.value === 'static';
|
||||
const body = { method: isNowStatic ? 'manual' : 'auto' };
|
||||
|
||||
if (isNowStatic) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue