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:
parent
342dc0f0cf
commit
4631ebc07a
4 changed files with 849 additions and 1 deletions
|
|
@ -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
345
backend/routers/network.py
Normal 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}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue