diff --git a/backend/main.py b/backend/main.py index 4d99e39..dd4236d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from backend.auth import authenticate_user, create_token, logout 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 logging.basicConfig( @@ -86,6 +86,7 @@ app.include_router(files.router) app.include_router(services.router) app.include_router(processes.router) app.include_router(settings.router) +app.include_router(network.router) app.include_router(asi_bridge.router) diff --git a/backend/routers/network.py b/backend/routers/network.py new file mode 100644 index 0000000..24832a8 --- /dev/null +++ b/backend/routers/network.py @@ -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"(?= 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 `).""" + 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} diff --git a/frontend/css/apps/settings.css b/frontend/css/apps/settings.css index c8a234c..c59b130 100644 --- a/frontend/css/apps/settings.css +++ b/frontend/css/apps/settings.css @@ -181,3 +181,113 @@ gap: 8px; 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; +} diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js index 103d0e8..be0d439 100644 --- a/frontend/js/apps/settings.js +++ b/frontend/js/apps/settings.js @@ -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) => + `${c}` + ).join(''); + } + async function renderNetwork() { + contentEl.innerHTML = '
Network
Loading…
'; + + // 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 = '
Network
Failed to load network status
'; + return; + } + + const status = await statusRes.json(); + const devices = status.devices; + + let html = '
Network
'; + + // ---- Device overview ---- + html += '
INTERFACES
'; + 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 += ` +
+
+
+
+
${dev.device}
+
${dev.type} · ${dev.connection || 'disconnected'}
+
+
+ ${ip} +
+ `; + } + html += '
'; + + // ---- Per-ethernet config ---- + const ethDevices = devices.filter(d => d.type === 'ethernet' && d.connection); + for (const dev of ethDevices) { + html += `
+
${dev.device.toUpperCase()} CONFIGURATION
+
Loading…
+
`; + } + + // ---- WiFi section ---- + const wifiDev = devices.find(d => d.type === 'wifi'); + if (wifiDev) { + html += ` +
+
WI-FI
+
+
+ +
+
+
CONNECT TO HIDDEN NETWORK
+
+
SSID
+ +
+
+
Password
+ +
+
+ +
+
+ `; + } + + // ---- Unconnected ethernet (no active connection) ---- + const disconnectedEth = devices.filter(d => d.type === 'ethernet' && !d.connection); + for (const dev of disconnectedEth) { + html += ` +
+
${dev.device.toUpperCase()}
+
+
Status
+ Disconnected +
+
+ `; + } + + 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 data = await res.json(); let html = '
Network
'; + html += '
NetworkManager not available — showing read-only status
'; const ifaces = data.network.interfaces; for (const [name, info] of Object.entries(ifaces)) { @@ -142,6 +283,257 @@ 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 += '
Failed to load configuration
'; + return; + } + + if (!connRes.ok) { + groupEl.innerHTML = `
${dev.device.toUpperCase()} CONFIGURATION
Failed to load configuration
`; + 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 = ` +
${dev.device.toUpperCase()} CONFIGURATION
+
+
+
IP Configuration
+
Toggle between DHCP and Static IP
+
+ +
+
+
+
IP Address
CIDR notation (e.g. 192.168.1.100/24)
+ +
+
+
Gateway
+ +
+
+
Primary DNS
+ +
+
+
Secondary DNS
+ +
+
+ +
+ +
+ `; + + // 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 = ` +
+
+
Connected to: ${wifiDev.connection}
+
${ip}
+
+ +
+ `; + el.querySelector('#wifiDisconnectBtn').addEventListener('click', async () => { + await Atlus.apiFetch('/api/network/wifi/disconnect', { method: 'POST' }); + setTimeout(() => renderNetwork(), 1500); + }); + } else { + el.innerHTML = ` +
+
Status
+ Disconnected +
+ `; + } + } + + async function _loadWifiNetworks() { + const listEl = contentEl.querySelector('#wifiNetworkList'); + if (!listEl) return; + listEl.innerHTML = '
Scanning…
'; + + const res = await Atlus.apiFetch('/api/network/wifi/list'); + if (!res.ok) { + listEl.innerHTML = '
Failed to scan WiFi networks
'; + return; + } + + const networks = await res.json(); + if (networks.length === 0) { + listEl.innerHTML = '
No networks found
'; + 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 ? ' ● CONNECTED' : ''; + + const item = document.createElement('div'); + item.className = 'wifi-network-item'; + item.innerHTML = ` +
+
${lockIcon}${net.ssid}${inUseTag}
+
${net.security || 'Open'} · ${sig.text} (${net.signal}%)
+
+
${_signalBars(net.signal)}
+ ${net.in_use ? '' : ''} + `; + + 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 = ` + + + + `; + + 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 = `
Connecting to ${ssid}…
`; + } + + 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 = `
Failed to connect: ${err.detail || 'Unknown error'}
`; + } + } + } + async function renderServicesConfig() { const cfgRes = await Atlus.apiFetch('/api/settings'); const cfg = await cfgRes.json();