Add network configuration to Settings (DHCP/static, WiFi scan/connect)

Full NetworkManager (nmcli) backed network management:
- Backend: new network.py router with endpoints for device status,
  connection config, IPv4 DHCP/static toggle, WiFi scan/connect/disconnect
- Frontend: interactive network settings UI with per-device config,
  WiFi network list with signal strength, inline password input
- Graceful 503 fallback to read-only psutil view when nmcli unavailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-14 18:29:35 -05:00
parent 342dc0f0cf
commit 4631ebc07a
4 changed files with 849 additions and 1 deletions

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT from backend.config import FRONTEND_DIR, HOST, PORT
from backend.routers import stats, terminal, files, services, processes, settings from backend.routers import stats, terminal, files, services, processes, settings, network
from backend.routers.plugins import asi_bridge from backend.routers.plugins import asi_bridge
logging.basicConfig( logging.basicConfig(
@ -86,6 +86,7 @@ app.include_router(files.router)
app.include_router(services.router) app.include_router(services.router)
app.include_router(processes.router) app.include_router(processes.router)
app.include_router(settings.router) app.include_router(settings.router)
app.include_router(network.router)
app.include_router(asi_bridge.router) app.include_router(asi_bridge.router)

345
backend/routers/network.py Normal file
View file

@ -0,0 +1,345 @@
"""Network configuration via NetworkManager (nmcli)."""
import asyncio
import ipaddress
import logging
import os
import re
import shutil
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.auth import get_current_user
router = APIRouter(prefix="/api/network", tags=["network"])
log = logging.getLogger("atlus.network")
_HAS_NMCLI = bool(shutil.which("nmcli"))
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class IPv4Config(BaseModel):
method: Literal["auto", "manual"]
address: Optional[str] = None # CIDR notation e.g. "192.168.1.100/24"
gateway: Optional[str] = None
dns: Optional[list[str]] = None
class WifiConnectRequest(BaseModel):
ssid: str
password: Optional[str] = None
hidden: bool = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_nmcli():
"""Raise 503 if NetworkManager is not available."""
if not _HAS_NMCLI:
raise HTTPException(503, "NetworkManager (nmcli) not available on this system")
async def _nmcli(*args: str, timeout: float = 30) -> str:
"""Run nmcli with C locale, return stdout. Raise HTTPException on failure."""
_require_nmcli()
cmd = ["nmcli"] + list(args)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "LC_ALL": "C"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise HTTPException(504, "nmcli operation timed out")
if proc.returncode != 0:
err_msg = stderr.decode().strip()
log.warning("nmcli failed (%d): %s", proc.returncode, err_msg)
raise HTTPException(500, f"nmcli error: {err_msg}")
return stdout.decode()
def _parse_terse(output: str, fields: list[str]) -> list[dict]:
"""Parse nmcli terse (-t) colon-separated output into list of dicts.
Handles escaped colons (\\:) in values.
"""
results = []
for line in output.strip().splitlines():
if not line:
continue
# Split on unescaped colons
parts = re.split(r"(?<!\\):", line)
parts = [p.replace("\\:", ":") for p in parts]
if len(parts) >= len(fields):
results.append(dict(zip(fields, parts[:len(fields)])))
return results
def _parse_keyvalue(output: str) -> dict[str, str]:
"""Parse nmcli key:value output (from `connection show <name>`)."""
props = {}
for line in output.strip().splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
props[key.strip()] = value.strip()
return props
def _validate_ipv4_config(cfg: IPv4Config):
"""Validate IPv4 configuration fields."""
if cfg.method == "manual":
if not cfg.address:
raise HTTPException(422, "IP address is required for static configuration")
if not cfg.gateway:
raise HTTPException(422, "Gateway is required for static configuration")
# Validate address (must be CIDR)
try:
ipaddress.ip_interface(cfg.address)
except ValueError:
raise HTTPException(422, f"Invalid IP address/prefix: {cfg.address}. Use CIDR notation (e.g. 192.168.1.100/24)")
# Validate gateway
try:
ipaddress.ip_address(cfg.gateway)
except ValueError:
raise HTTPException(422, f"Invalid gateway address: {cfg.gateway}")
# Validate DNS
if cfg.dns:
for dns in cfg.dns:
try:
ipaddress.ip_address(dns)
except ValueError:
raise HTTPException(422, f"Invalid DNS address: {dns}")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/status")
async def network_status(_user: str = Depends(get_current_user)):
"""Get device list and active connections."""
_require_nmcli()
# Device status
dev_out = await _nmcli("-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status")
devices = _parse_terse(dev_out, ["device", "type", "state", "connection"])
# Filter out loopback and uninteresting devices
devices = [d for d in devices if d["type"] in ("ethernet", "wifi", "bridge")]
# Get IP addresses for connected devices
for dev in devices:
dev["ipv4"] = None
if dev["state"] == "connected" and dev["connection"]:
try:
conn_out = await _nmcli("-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS",
"connection", "show", dev["connection"])
props = _parse_keyvalue(conn_out)
# nmcli may output IP4.ADDRESS[1], IP4.DNS[1], etc.
dev["ipv4"] = {
"address": props.get("IP4.ADDRESS[1]", ""),
"gateway": props.get("IP4.GATEWAY", ""),
"dns": [],
}
# Collect all DNS entries
for k, v in props.items():
if k.startswith("IP4.DNS") and v:
dev["ipv4"]["dns"].append(v)
except HTTPException:
pass # Connection details unavailable
return {"devices": devices}
@router.get("/connections")
async def list_connections(_user: str = Depends(get_current_user)):
"""List all saved NetworkManager connections."""
out = await _nmcli("-t", "-f", "NAME,UUID,TYPE,DEVICE", "connection", "show")
connections = _parse_terse(out, ["name", "uuid", "type", "device"])
return connections
@router.get("/connection/{name}")
async def connection_detail(name: str, _user: str = Depends(get_current_user)):
"""Get detailed info for a single connection."""
try:
out = await _nmcli("connection", "show", name)
except HTTPException:
raise HTTPException(404, f"Connection not found: {name}")
props = _parse_keyvalue(out)
# Extract IPv4 config (what's configured)
ipv4_method = props.get("ipv4.method", "auto")
ipv4_addresses = props.get("ipv4.addresses", "")
ipv4_gateway = props.get("ipv4.gateway", "")
ipv4_dns = props.get("ipv4.dns", "")
# Extract active IPv4 (what's currently in use)
active_address = props.get("IP4.ADDRESS[1]", "")
active_gateway = props.get("IP4.GATEWAY", "")
active_dns = []
for k, v in props.items():
if k.startswith("IP4.DNS") and v:
active_dns.append(v)
return {
"name": props.get("connection.id", name),
"uuid": props.get("connection.uuid", ""),
"type": props.get("connection.type", ""),
"device": props.get("GENERAL.DEVICES", props.get("connection.interface-name", "")),
"state": props.get("GENERAL.STATE", ""),
"ipv4": {
"method": ipv4_method,
"addresses": ipv4_addresses,
"gateway": ipv4_gateway,
"dns": ipv4_dns,
},
"active_ipv4": {
"address": active_address,
"gateway": active_gateway,
"dns": active_dns,
},
}
@router.put("/connection/{name}/ipv4")
async def configure_ipv4(
name: str,
cfg: IPv4Config,
_user: str = Depends(get_current_user),
):
"""Set IPv4 configuration (DHCP or static) and apply."""
_validate_ipv4_config(cfg)
if cfg.method == "auto":
# Switch to DHCP — clear static settings
await _nmcli("connection", "modify", name,
"ipv4.method", "auto",
"ipv4.addresses", "",
"ipv4.gateway", "",
"ipv4.dns", "")
else:
# Static configuration
dns_str = " ".join(cfg.dns) if cfg.dns else ""
modify_args = [
"connection", "modify", name,
"ipv4.method", "manual",
"ipv4.addresses", cfg.address,
"ipv4.gateway", cfg.gateway,
]
if dns_str:
modify_args.extend(["ipv4.dns", dns_str])
else:
modify_args.extend(["ipv4.dns", ""])
await _nmcli(*modify_args)
# Apply the changes
try:
await _nmcli("connection", "up", name, timeout=15)
except HTTPException as e:
# Connection up may fail if we just changed the IP of our own interface
# Return success anyway — the config was saved
log.warning("connection up after config change: %s", e.detail)
return {"success": True, "method": cfg.method}
@router.get("/wifi/list")
async def wifi_scan(_user: str = Depends(get_current_user)):
"""Scan for available WiFi networks."""
_require_nmcli()
# Find the wifi device
dev_out = await _nmcli("-t", "-f", "DEVICE,TYPE,STATE", "device", "status")
devices = _parse_terse(dev_out, ["device", "type", "state"])
wifi_dev = next((d for d in devices if d["type"] == "wifi"), None)
if not wifi_dev:
raise HTTPException(404, "No WiFi device found")
# Scan
try:
out = await _nmcli("-t", "-f", "SSID,BSSID,SIGNAL,SECURITY,IN-USE",
"device", "wifi", "list", "--rescan", "yes",
timeout=20)
except HTTPException:
# Rescan may fail; try without rescan
out = await _nmcli("-t", "-f", "SSID,BSSID,SIGNAL,SECURITY,IN-USE",
"device", "wifi", "list")
networks = _parse_terse(out, ["ssid", "bssid", "signal", "security", "in_use"])
# Filter empty SSIDs, deduplicate by SSID (keep strongest signal)
seen = {}
for net in networks:
if not net["ssid"]:
continue
net["signal"] = int(net["signal"]) if net["signal"].isdigit() else 0
net["in_use"] = net["in_use"] == "*"
existing = seen.get(net["ssid"])
if not existing or net["signal"] > existing["signal"]:
seen[net["ssid"]] = net
result = sorted(seen.values(), key=lambda x: x["signal"], reverse=True)
return result
@router.post("/wifi/connect")
async def wifi_connect(req: WifiConnectRequest, _user: str = Depends(get_current_user)):
"""Connect to a WiFi network."""
_require_nmcli()
if not req.ssid or len(req.ssid) > 64:
raise HTTPException(422, "Invalid SSID")
args = ["device", "wifi", "connect", req.ssid]
if req.password:
args.extend(["password", req.password])
if req.hidden:
args.extend(["hidden", "yes"])
await _nmcli(*args, timeout=30)
return {"success": True, "ssid": req.ssid}
@router.post("/wifi/disconnect")
async def wifi_disconnect(_user: str = Depends(get_current_user)):
"""Disconnect the WiFi device."""
_require_nmcli()
# Find the wifi device name
dev_out = await _nmcli("-t", "-f", "DEVICE,TYPE", "device", "status")
devices = _parse_terse(dev_out, ["device", "type"])
wifi_dev = next((d for d in devices if d["type"] == "wifi"), None)
if not wifi_dev:
raise HTTPException(404, "No WiFi device found")
await _nmcli("device", "disconnect", wifi_dev["device"])
return {"success": True}
@router.delete("/connection/{name}")
async def delete_connection(name: str, _user: str = Depends(get_current_user)):
"""Delete a saved connection."""
await _nmcli("connection", "delete", name)
return {"success": True}
@router.post("/connection/{name}/apply")
async def apply_connection(name: str, _user: str = Depends(get_current_user)):
"""Reapply/reconnect a connection."""
await _nmcli("connection", "up", name, timeout=15)
return {"success": True}

View file

@ -181,3 +181,113 @@
gap: 8px; gap: 8px;
margin-top: 24px; margin-top: 24px;
} }
/* ---- Network configuration ---- */
.net-error {
color: var(--status-red);
font-family: var(--font-mono);
font-size: 13px;
padding: 8px 0;
}
.net-error.hidden {
display: none;
}
.net-static-fields {
transition: opacity 0.15s ease;
}
.net-static-fields.hidden {
display: none;
}
.settings-toggle .toggle-label {
position: absolute;
left: 60px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
/* WiFi network list */
.wifi-network-list {
max-height: 360px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
margin-top: 12px;
}
.wifi-network-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-structural);
flex-wrap: wrap;
}
.wifi-network-info {
flex: 1;
min-width: 0;
}
.wifi-ssid {
font-family: var(--font-ui);
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wifi-detail {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.wifi-signal {
font-size: 16px;
letter-spacing: 1px;
flex-shrink: 0;
}
.wifi-connect-btn {
flex-shrink: 0;
height: 36px !important;
padding: 0 14px !important;
font-size: 13px !important;
}
.wifi-password-row {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 0;
width: 100%;
flex-wrap: wrap;
}
.wifi-pass-input {
flex: 1;
min-width: 160px;
}
.wifi-pass-connect,
.wifi-pass-cancel {
height: 36px !important;
padding: 0 14px !important;
font-size: 13px !important;
}
.settings-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -112,11 +112,152 @@
}); });
} }
// ---------------------------------------------------------------
// Network section — full NetworkManager integration
// ---------------------------------------------------------------
function _signalLabel(signal) {
if (signal >= 80) return { text: 'Excellent', color: 'var(--status-green)' };
if (signal >= 60) return { text: 'Good', color: 'var(--status-green)' };
if (signal >= 40) return { text: 'Fair', color: 'var(--status-amber, #e09a2a)' };
return { text: 'Weak', color: 'var(--status-red)' };
}
function _signalBars(signal) {
const filled = Math.ceil(signal / 25);
return '▂▄▆█'.split('').map((c, i) =>
`<span style="color:${i < filled ? 'var(--accent)' : 'var(--text-muted)'};">${c}</span>`
).join('');
}
async function renderNetwork() { async function renderNetwork() {
contentEl.innerHTML = '<div class="settings-section-title">Network</div><div style="padding:16px;color:var(--text-muted);font-family:var(--font-mono);font-size:13px;">Loading…</div>';
// Try NetworkManager first
const statusRes = await Atlus.apiFetch('/api/network/status');
if (statusRes.status === 503) {
// No nmcli — fall back to read-only psutil view
return await _renderNetworkReadonly();
}
if (!statusRes.ok) {
contentEl.innerHTML = '<div class="settings-section-title">Network</div><div class="net-error">Failed to load network status</div>';
return;
}
const status = await statusRes.json();
const devices = status.devices;
let html = '<div class="settings-section-title">Network</div>';
// ---- Device overview ----
html += '<div class="settings-group"><div class="settings-group-title">INTERFACES</div>';
for (const dev of devices) {
const isUp = dev.state === 'connected';
const dotColor = isUp ? 'var(--status-green)' : 'var(--status-red)';
const ip = dev.ipv4 && dev.ipv4.address ? dev.ipv4.address : '--';
html += `
<div class="settings-row">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
<div>
<div class="settings-row-label">${dev.device}</div>
<div class="settings-row-desc">${dev.type} · ${dev.connection || 'disconnected'}</div>
</div>
</div>
<span style="font-family:var(--font-mono);font-size:13px;color:var(--text-secondary);">${ip}</span>
</div>
`;
}
html += '</div>';
// ---- Per-ethernet config ----
const ethDevices = devices.filter(d => d.type === 'ethernet' && d.connection);
for (const dev of ethDevices) {
html += `<div class="settings-group" id="ethConfig-${dev.device}">
<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div>
<div style="padding:8px 0;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Loading</div>
</div>`;
}
// ---- WiFi section ----
const wifiDev = devices.find(d => d.type === 'wifi');
if (wifiDev) {
html += `
<div class="settings-group" id="wifiSection">
<div class="settings-group-title">WI-FI</div>
<div id="wifiStatus"></div>
<div style="margin-top:12px;">
<button class="settings-btn secondary" id="wifiScanBtn">Scan Networks</button>
</div>
<div id="wifiNetworkList" class="wifi-network-list"></div>
<div class="settings-group-title" style="margin-top:16px;">CONNECT TO HIDDEN NETWORK</div>
<div class="settings-row">
<div><div class="settings-row-label">SSID</div></div>
<input class="settings-input" id="hiddenSsid" placeholder="Network name">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Password</div></div>
<input class="settings-input" id="hiddenPass" type="password" placeholder="Password">
</div>
<div class="settings-actions">
<button class="settings-btn secondary" id="hiddenConnectBtn">Connect</button>
</div>
</div>
`;
}
// ---- Unconnected ethernet (no active connection) ----
const disconnectedEth = devices.filter(d => d.type === 'ethernet' && !d.connection);
for (const dev of disconnectedEth) {
html += `
<div class="settings-group">
<div class="settings-group-title">${dev.device.toUpperCase()}</div>
<div class="settings-row">
<div class="settings-row-label">Status</div>
<span style="color:var(--status-red);">Disconnected</span>
</div>
</div>
`;
}
contentEl.innerHTML = html;
// ---- Load ethernet configs ----
for (const dev of ethDevices) {
_loadEthernetConfig(dev);
}
// ---- WiFi event handlers ----
if (wifiDev) {
_renderWifiStatus(wifiDev);
contentEl.querySelector('#wifiScanBtn').addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Scanning…';
await _loadWifiNetworks();
btn.disabled = false;
btn.textContent = 'Scan Networks';
});
contentEl.querySelector('#hiddenConnectBtn').addEventListener('click', async () => {
const ssid = contentEl.querySelector('#hiddenSsid').value.trim();
const pass = contentEl.querySelector('#hiddenPass').value;
if (!ssid) return;
await _connectWifi(ssid, pass, true);
});
}
}
async function _renderNetworkReadonly() {
// Fallback: read-only view from psutil stats
const res = await Atlus.apiFetch('/api/stats'); const res = await Atlus.apiFetch('/api/stats');
const data = await res.json(); const data = await res.json();
let html = '<div class="settings-section-title">Network</div>'; let html = '<div class="settings-section-title">Network</div>';
html += '<div class="net-error" style="margin-bottom:16px;">NetworkManager not available — showing read-only status</div>';
const ifaces = data.network.interfaces; const ifaces = data.network.interfaces;
for (const [name, info] of Object.entries(ifaces)) { for (const [name, info] of Object.entries(ifaces)) {
@ -142,6 +283,257 @@
contentEl.innerHTML = html; contentEl.innerHTML = html;
} }
async function _loadEthernetConfig(dev) {
const groupEl = contentEl.querySelector(`#ethConfig-${dev.device}`);
if (!groupEl) return;
let connRes;
try {
connRes = await Atlus.apiFetch(`/api/network/connection/${encodeURIComponent(dev.connection)}`);
} catch (e) {
groupEl.innerHTML += '<div class="net-error">Failed to load configuration</div>';
return;
}
if (!connRes.ok) {
groupEl.innerHTML = `<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div><div class="net-error">Failed to load configuration</div>`;
return;
}
const conn = await connRes.json();
const isStatic = conn.ipv4.method === 'manual';
const currentAddr = conn.active_ipv4.address || conn.ipv4.addresses || '';
const currentGw = conn.active_ipv4.gateway || conn.ipv4.gateway || '';
const currentDns = conn.active_ipv4.dns.length ? conn.active_ipv4.dns : (conn.ipv4.dns ? conn.ipv4.dns.split(',').map(s => s.trim()) : []);
groupEl.innerHTML = `
<div class="settings-group-title">${dev.device.toUpperCase()} CONFIGURATION</div>
<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>
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${dev.device}">
<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>
<input class="settings-input" id="ethAddr-${dev.device}" value="${currentAddr}" placeholder="192.168.1.100/24">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Gateway</div></div>
<input class="settings-input" id="ethGw-${dev.device}" value="${currentGw}" placeholder="192.168.1.1">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Primary DNS</div></div>
<input class="settings-input" id="ethDns1-${dev.device}" value="${currentDns[0] || ''}" placeholder="8.8.8.8">
</div>
<div class="settings-row">
<div><div class="settings-row-label">Secondary DNS</div></div>
<input class="settings-input" id="ethDns2-${dev.device}" value="${currentDns[1] || ''}" placeholder="8.8.4.4">
</div>
</div>
<div id="ethError-${dev.device}" class="net-error hidden"></div>
<div class="settings-actions">
<button class="settings-btn" id="ethApply-${dev.device}">Apply</button>
</div>
`;
// Toggle handler
const toggle = groupEl.querySelector(`#dhcpToggle-${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);
});
// Apply handler
groupEl.querySelector(`#ethApply-${dev.device}`).addEventListener('click', async () => {
const errEl = groupEl.querySelector(`#ethError-${dev.device}`);
errEl.classList.add('hidden');
const isNowStatic = toggle.classList.contains('on');
const body = { method: isNowStatic ? 'manual' : 'auto' };
if (isNowStatic) {
body.address = groupEl.querySelector(`#ethAddr-${dev.device}`).value.trim();
body.gateway = groupEl.querySelector(`#ethGw-${dev.device}`).value.trim();
const dns1 = groupEl.querySelector(`#ethDns1-${dev.device}`).value.trim();
const dns2 = groupEl.querySelector(`#ethDns2-${dev.device}`).value.trim();
body.dns = [dns1, dns2].filter(Boolean);
if (!body.address || !body.gateway) {
errEl.textContent = 'IP address and gateway are required for static configuration.';
errEl.classList.remove('hidden');
return;
}
}
// Warn if configuring the interface we're connected through
if (confirm(`Apply network changes to ${dev.device}? If you are connected through this interface, you may lose access temporarily.`)) {
const applyBtn = groupEl.querySelector(`#ethApply-${dev.device}`);
applyBtn.disabled = true;
applyBtn.textContent = 'Applying…';
const res = await Atlus.apiFetch(`/api/network/connection/${encodeURIComponent(dev.connection)}/ipv4`, {
method: 'PUT',
body: body,
});
if (res && res.ok) {
applyBtn.textContent = 'Applied ✓';
setTimeout(() => renderNetwork(), 3000);
} else {
const err = res ? await res.json().catch(() => ({})) : {};
errEl.textContent = err.detail || 'Failed to apply configuration.';
errEl.classList.remove('hidden');
applyBtn.disabled = false;
applyBtn.textContent = 'Apply';
}
}
});
}
function _renderWifiStatus(wifiDev) {
const el = contentEl.querySelector('#wifiStatus');
if (!el) return;
if (wifiDev.state === 'connected' && wifiDev.connection) {
const ip = wifiDev.ipv4 && wifiDev.ipv4.address ? wifiDev.ipv4.address : '--';
el.innerHTML = `
<div class="settings-row">
<div>
<div class="settings-row-label">Connected to: ${wifiDev.connection}</div>
<div class="settings-row-desc">${ip}</div>
</div>
<button class="settings-btn secondary" id="wifiDisconnectBtn">Disconnect</button>
</div>
`;
el.querySelector('#wifiDisconnectBtn').addEventListener('click', async () => {
await Atlus.apiFetch('/api/network/wifi/disconnect', { method: 'POST' });
setTimeout(() => renderNetwork(), 1500);
});
} else {
el.innerHTML = `
<div class="settings-row">
<div class="settings-row-label">Status</div>
<span style="color:var(--status-red);">Disconnected</span>
</div>
`;
}
}
async function _loadWifiNetworks() {
const listEl = contentEl.querySelector('#wifiNetworkList');
if (!listEl) return;
listEl.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Scanning…</div>';
const res = await Atlus.apiFetch('/api/network/wifi/list');
if (!res.ok) {
listEl.innerHTML = '<div class="net-error">Failed to scan WiFi networks</div>';
return;
}
const networks = await res.json();
if (networks.length === 0) {
listEl.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">No networks found</div>';
return;
}
listEl.innerHTML = '';
for (const net of networks) {
const sig = _signalLabel(net.signal);
const isOpen = !net.security || net.security === '' || net.security === '--';
const lockIcon = isOpen ? '' : '🔒 ';
const inUseTag = net.in_use ? ' <span style="color:var(--accent);font-size:11px;">● CONNECTED</span>' : '';
const item = document.createElement('div');
item.className = 'wifi-network-item';
item.innerHTML = `
<div class="wifi-network-info">
<div class="wifi-ssid">${lockIcon}${net.ssid}${inUseTag}</div>
<div class="wifi-detail">${net.security || 'Open'} · <span style="color:${sig.color};">${sig.text} (${net.signal}%)</span></div>
</div>
<div class="wifi-signal">${_signalBars(net.signal)}</div>
${net.in_use ? '' : '<button class="settings-btn secondary wifi-connect-btn">Connect</button>'}
`;
if (!net.in_use) {
const connectBtn = item.querySelector('.wifi-connect-btn');
connectBtn.addEventListener('click', () => {
if (isOpen) {
_connectWifi(net.ssid, null, false);
} else {
_showWifiPasswordInput(item, net.ssid);
}
});
}
listEl.appendChild(item);
}
}
function _showWifiPasswordInput(itemEl, ssid) {
// Check if already showing
if (itemEl.querySelector('.wifi-password-row')) return;
const row = document.createElement('div');
row.className = 'wifi-password-row';
row.innerHTML = `
<input class="settings-input wifi-pass-input" type="password" placeholder="Password for ${ssid}">
<button class="settings-btn wifi-pass-connect">Connect</button>
<button class="settings-btn secondary wifi-pass-cancel">Cancel</button>
`;
row.querySelector('.wifi-pass-connect').addEventListener('click', () => {
const pass = row.querySelector('.wifi-pass-input').value;
_connectWifi(ssid, pass, false);
});
row.querySelector('.wifi-pass-cancel').addEventListener('click', () => {
row.remove();
});
// Enter key support
row.querySelector('.wifi-pass-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const pass = row.querySelector('.wifi-pass-input').value;
_connectWifi(ssid, pass, false);
}
});
itemEl.appendChild(row);
row.querySelector('.wifi-pass-input').focus();
}
async function _connectWifi(ssid, password, hidden) {
const body = { ssid, hidden };
if (password) body.password = password;
const listEl = contentEl.querySelector('#wifiNetworkList');
if (listEl) {
listEl.innerHTML = `<div style="padding:12px;color:var(--text-muted);font-family:var(--font-mono);font-size:12px;">Connecting to ${ssid}…</div>`;
}
const res = await Atlus.apiFetch('/api/network/wifi/connect', {
method: 'POST',
body: body,
});
if (res && res.ok) {
setTimeout(() => renderNetwork(), 2000);
} else {
const err = res ? await res.json().catch(() => ({})) : {};
if (listEl) {
listEl.innerHTML = `<div class="net-error">Failed to connect: ${err.detail || 'Unknown error'}</div>`;
}
}
}
async function renderServicesConfig() { async function renderServicesConfig() {
const cfgRes = await Atlus.apiFetch('/api/settings'); const cfgRes = await Atlus.apiFetch('/api/settings');
const cfg = await cfgRes.json(); const cfg = await cfgRes.json();