530 lines
20 KiB
Python
530 lines
20 KiB
Python
"""Network quad.
|
|
|
|
Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh).
|
|
Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs —
|
|
Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address,
|
|
subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up`
|
|
Routes : the kernel routing table (read-only; editing kernel routes needs root)
|
|
VLAN : list / create / activate / delete VLAN connections
|
|
DNS : effective servers per device + a per-connection override table
|
|
Every tab is an independently toggle-able feature.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import sys
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
from gi.repository import Gtk # noqa: E402
|
|
|
|
from lib.proc import run_json
|
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
|
from paths import BACKEND_DIR
|
|
|
|
_NET = str(BACKEND_DIR / "network.sh")
|
|
_NM = str(BACKEND_DIR / "nm.py")
|
|
|
|
|
|
def _nm(args: list[str], cb) -> None:
|
|
"""Run a backend/nm.py subcommand expecting JSON on stdout."""
|
|
run_json([sys.executable, _NM, *args], cb)
|
|
|
|
|
|
def _primary_ip(data) -> str:
|
|
if not isinstance(data, list):
|
|
return "—"
|
|
for iface in data:
|
|
if iface.get("ifname") == "lo":
|
|
continue
|
|
for a in iface.get("addr_info", []):
|
|
if a.get("family") == "inet":
|
|
return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})"
|
|
return "—"
|
|
|
|
|
|
# -- subnet mask <-> CIDR prefix ------------------------------------------
|
|
def _mask_to_prefix(mask: str) -> int | None:
|
|
try:
|
|
return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen
|
|
except (ipaddress.NetmaskValueError, ValueError):
|
|
return None
|
|
|
|
|
|
def _prefix_to_mask(prefix: int) -> str:
|
|
return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask)
|
|
|
|
|
|
class _Compact(Gtk.Box):
|
|
def __init__(self, ctx: ModuleContext):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self.add_css_class("net-view")
|
|
self.ctx = ctx
|
|
self.net = ctx.services.network
|
|
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
self._primary = Gtk.Label(label="…", xalign=0.0, hexpand=True)
|
|
row.append(Gtk.Label(label="")) # nf wifi
|
|
row.append(self._primary)
|
|
wifi = self.net.get_wifi() if self.net else None
|
|
self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()),
|
|
valign=Gtk.Align.CENTER)
|
|
self._wifi_switch.set_tooltip_text("Wi-Fi")
|
|
self._wifi_switch.connect("state-set", self._on_wifi_toggle)
|
|
row.append(self._wifi_switch)
|
|
self.append(row)
|
|
|
|
self._ip = Gtk.Label(label="", xalign=0.0)
|
|
self._ip.add_css_class("net-ip")
|
|
self.append(self._ip)
|
|
|
|
if self.net:
|
|
self.net.connect("notify::primary", lambda *_: self.refresh())
|
|
if wifi:
|
|
wifi.connect("notify::ssid", lambda *_: self.refresh())
|
|
self.refresh()
|
|
|
|
def _on_wifi_toggle(self, _sw, value: bool) -> bool:
|
|
wifi = self.net.get_wifi() if self.net else None
|
|
if wifi:
|
|
wifi.set_enabled(value)
|
|
return False
|
|
|
|
def refresh(self) -> None:
|
|
wifi = self.net.get_wifi() if self.net else None
|
|
if wifi and wifi.get_active_access_point():
|
|
self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%")
|
|
elif self.net and self.net.get_wired() and self.net.get_wired().get_internet():
|
|
self._primary.set_text("Wired")
|
|
else:
|
|
self._primary.set_text("Disconnected")
|
|
run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else "—"))
|
|
|
|
|
|
# -- shared little widgets -------------------------------------------------
|
|
def _clear(box: Gtk.Box) -> None:
|
|
child = box.get_first_child()
|
|
while child:
|
|
box.remove(child)
|
|
child = box.get_first_child()
|
|
|
|
|
|
def _field(placeholder: str, text: str = "") -> Gtk.Entry:
|
|
e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True)
|
|
e.add_css_class("net-entry")
|
|
return e
|
|
|
|
|
|
def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget:
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
lbl = Gtk.Label(label=label, xalign=0.0)
|
|
lbl.set_size_request(90, -1)
|
|
row.append(lbl)
|
|
row.append(widget)
|
|
return row
|
|
|
|
|
|
def _pill(label: str, cb) -> Gtk.Button:
|
|
b = Gtk.Button(label=label)
|
|
b.add_css_class("quad-action")
|
|
b.connect("clicked", lambda *_: cb())
|
|
return b
|
|
|
|
|
|
# -- IPv4 / IPv6 editor for one connection --------------------------------
|
|
class _IPSection(Gtk.Box):
|
|
"""One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair;
|
|
IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode."""
|
|
|
|
def __init__(self, con: str, family: str, fam_data: dict, on_result):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
self.con = con
|
|
self.family = family
|
|
self._on_result = on_result
|
|
self.add_css_class("net-view")
|
|
|
|
title = "IPv4" if family == "ipv4" else "IPv6"
|
|
head = Gtk.CenterBox()
|
|
head.set_start_widget(Gtk.Label(label=title, xalign=0.0))
|
|
auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
auto.append(Gtk.Label(label="Automatic"))
|
|
self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"),
|
|
valign=Gtk.Align.CENTER)
|
|
self._dhcp.connect("state-set", self._on_mode)
|
|
auto.append(self._dhcp)
|
|
head.set_end_widget(auto)
|
|
self.append(head)
|
|
|
|
addr, prefix = self._split(fam_data.get("addresses", []))
|
|
self._addr = _field("Address", addr)
|
|
self._gw = _field("Gateway", fam_data.get("gateway", ""))
|
|
|
|
self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
self._fields.append(_labeled("Address", self._addr))
|
|
if family == "ipv4":
|
|
self._guard = False
|
|
self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "")
|
|
self._cidr = _field("/24", f"/{prefix}" if prefix else "")
|
|
self._cidr.set_size_request(70, -1)
|
|
self._mask.connect("changed", self._sync_from_mask)
|
|
self._cidr.connect("changed", self._sync_from_cidr)
|
|
mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
mask_row.append(self._mask)
|
|
mask_row.append(self._cidr)
|
|
self._fields.append(_labeled("Netmask", mask_row))
|
|
else:
|
|
self._prefix = _field("64", str(prefix) if prefix else "")
|
|
self._fields.append(_labeled("Prefix", self._prefix))
|
|
self._fields.append(_labeled("Gateway", self._gw))
|
|
apply = Gtk.Button(label=f"Apply {title}")
|
|
apply.add_css_class("enable-btn")
|
|
apply.connect("clicked", lambda *_: self._apply())
|
|
self._fields.append(apply)
|
|
self.append(self._fields)
|
|
|
|
self._set_manual_enabled(not self._dhcp.get_active())
|
|
|
|
def _set_manual_enabled(self, on: bool) -> None:
|
|
# The reset GTK theme doesn't dim insensitive widgets, so fade the manual
|
|
# fields explicitly when DHCP/Automatic is active.
|
|
self._fields.set_sensitive(on)
|
|
self._fields.set_opacity(1.0 if on else 0.45)
|
|
|
|
@staticmethod
|
|
def _split(addresses: list[str]) -> tuple[str, int | None]:
|
|
if not addresses:
|
|
return "", None
|
|
addr, _, prefix = addresses[0].partition("/")
|
|
try:
|
|
return addr, int(prefix)
|
|
except ValueError:
|
|
return addr, None
|
|
|
|
def _on_mode(self, _sw, auto: bool) -> bool:
|
|
self._set_manual_enabled(not auto)
|
|
return False
|
|
|
|
# keep the dotted mask and /CIDR fields in lock-step (IPv4 only)
|
|
def _sync_from_mask(self, _e) -> None:
|
|
if self._guard:
|
|
return
|
|
p = _mask_to_prefix(self._mask.get_text())
|
|
if p is not None:
|
|
self._guard = True
|
|
self._cidr.set_text(f"/{p}")
|
|
self._guard = False
|
|
|
|
def _sync_from_cidr(self, _e) -> None:
|
|
if self._guard:
|
|
return
|
|
raw = self._cidr.get_text().lstrip("/").strip()
|
|
if raw.isdigit() and 0 <= int(raw) <= 32:
|
|
self._guard = True
|
|
self._mask.set_text(_prefix_to_mask(int(raw)))
|
|
self._guard = False
|
|
|
|
def _prefixlen(self) -> str:
|
|
if self.family == "ipv4":
|
|
raw = self._cidr.get_text().lstrip("/").strip()
|
|
if raw.isdigit():
|
|
return raw
|
|
p = _mask_to_prefix(self._mask.get_text())
|
|
return str(p) if p is not None else ""
|
|
return self._prefix.get_text().strip()
|
|
|
|
def _apply(self) -> None:
|
|
if self._dhcp.get_active():
|
|
args = ["set-ip", self.con, self.family, "auto"]
|
|
else:
|
|
addr, plen = self._addr.get_text().strip(), self._prefixlen()
|
|
cidr = f"{addr}/{plen}" if addr and plen else addr
|
|
args = ["set-ip", self.con, self.family, "manual",
|
|
cidr, self._gw.get_text().strip(), ""]
|
|
_nm(args, lambda ok, d: self._on_result(
|
|
ok and isinstance(d, dict) and d.get("ok"),
|
|
(d.get("error") if isinstance(d, dict) else "") or ""))
|
|
|
|
|
|
class _AdaptersPage(Gtk.Box):
|
|
def __init__(self):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
self._status = Gtk.Label(xalign=0.0)
|
|
self._status.add_css_class("net-ip")
|
|
self._status.set_visible(False)
|
|
self.append(self._status)
|
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
|
scroller.set_child(self._list)
|
|
self.append(scroller)
|
|
self._reload()
|
|
|
|
def _reload(self) -> None:
|
|
_nm(["adapters"], self._populate)
|
|
|
|
def _populate(self, ok: bool, adapters) -> None:
|
|
_clear(self._list)
|
|
if not ok or not isinstance(adapters, list) or not adapters:
|
|
self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0))
|
|
return
|
|
for a in adapters:
|
|
self._list.append(self._adapter_row(a))
|
|
|
|
def _adapter_row(self, a: dict) -> Gtk.Widget:
|
|
icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet
|
|
exp = Gtk.Expander(
|
|
label=f"{icon} {a['device']} · {a.get('connection') or '—'} [{a.get('state')}]")
|
|
exp.add_css_class("net-adapter")
|
|
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
body.set_margin_top(8)
|
|
con = a.get("connection")
|
|
if not con:
|
|
body.append(Gtk.Label(label="No connection profile", xalign=0.0))
|
|
else:
|
|
loading = Gtk.Label(label="Loading…", xalign=0.0)
|
|
body.append(loading)
|
|
_nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con:
|
|
self._fill(ok, d, b, l, c))
|
|
exp.set_child(body)
|
|
return exp
|
|
|
|
def _fill(self, ok, data, body, loading, con) -> None:
|
|
body.remove(loading)
|
|
if not ok or not isinstance(data, dict):
|
|
body.append(Gtk.Label(label="Could not read config", xalign=0.0))
|
|
return
|
|
result = Gtk.Label(xalign=0.0)
|
|
result.set_visible(False)
|
|
|
|
def on_result(good: bool, err: str) -> None:
|
|
result.set_text("Applied" if good else f"Failed: {err or 'error'}")
|
|
result.set_visible(True)
|
|
|
|
body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result))
|
|
body.append(Gtk.Separator())
|
|
body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result))
|
|
body.append(result)
|
|
|
|
|
|
class _RoutesPage(Gtk.Box):
|
|
def __init__(self):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
header = Gtk.CenterBox()
|
|
header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0))
|
|
header.set_end_widget(_pill(" Refresh", self._reload))
|
|
self.append(header)
|
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
|
scroller.set_child(self._list)
|
|
self.append(scroller)
|
|
self._reload()
|
|
|
|
def _reload(self) -> None:
|
|
_nm(["routes"], self._populate)
|
|
|
|
def _populate(self, ok: bool, routes) -> None:
|
|
_clear(self._list)
|
|
if not ok or not isinstance(routes, list) or not routes:
|
|
self._list.append(Gtk.Label(label="No routes", xalign=0.0))
|
|
return
|
|
for r in routes:
|
|
self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0,
|
|
selectable=True, wrap=True))
|
|
|
|
@staticmethod
|
|
def _fmt(r: dict) -> str:
|
|
dst = r.get("dst", "?")
|
|
via = f" via {r['gateway']}" if r.get("gateway") else ""
|
|
dev = f" dev {r['dev']}" if r.get("dev") else ""
|
|
metric = f" metric {r['metric']}" if r.get("metric") is not None else ""
|
|
proto = f" ({r['proto']})" if r.get("proto") else ""
|
|
return f"{dst}{via}{dev}{metric}{proto}"
|
|
|
|
|
|
class _VlanPage(Gtk.Box):
|
|
def __init__(self):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self._adapters: list[dict] = []
|
|
|
|
add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
self._parent = Gtk.DropDown.new_from_strings(["…"])
|
|
self._vid = _field("VLAN ID")
|
|
self._vid.set_size_request(90, -1)
|
|
add.append(self._parent)
|
|
add.append(self._vid)
|
|
add.append(_pill("Add", self._add))
|
|
self.append(_labeled("New VLAN", add))
|
|
|
|
self._status = Gtk.Label(xalign=0.0)
|
|
self._status.add_css_class("net-ip")
|
|
self._status.set_visible(False)
|
|
self.append(self._status)
|
|
|
|
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
|
scroller.set_child(self._list)
|
|
self.append(scroller)
|
|
|
|
_nm(["adapters"], self._set_adapters)
|
|
self._reload()
|
|
|
|
def _set_adapters(self, ok, adapters) -> None:
|
|
self._adapters = adapters if ok and isinstance(adapters, list) else []
|
|
names = [a["device"] for a in self._adapters] or ["(no devices)"]
|
|
self._parent.set_model(Gtk.StringList.new(names))
|
|
|
|
def _reload(self) -> None:
|
|
_nm(["vlans"], self._populate)
|
|
|
|
def _populate(self, ok, vlans) -> None:
|
|
_clear(self._list)
|
|
if not ok or not isinstance(vlans, list) or not vlans:
|
|
self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0))
|
|
return
|
|
for v in vlans:
|
|
self._list.append(self._vlan_row(v))
|
|
|
|
def _vlan_row(self, v: dict) -> Gtk.Widget:
|
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
row.add_css_class("net-row")
|
|
state = "active" if v.get("active") else "inactive"
|
|
row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or '—'} [{state}]",
|
|
xalign=0.0, hexpand=True))
|
|
if not v.get("active"):
|
|
row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n)))
|
|
row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n)))
|
|
return row
|
|
|
|
def _add(self) -> None:
|
|
idx = self._parent.get_selected()
|
|
vid = self._vid.get_text().strip()
|
|
if idx < 0 or idx >= len(self._adapters) or not vid.isdigit():
|
|
self._flash("Pick a parent device and numeric VLAN ID")
|
|
return
|
|
parent = self._adapters[idx]["device"]
|
|
_nm(["vlan-add", parent, vid], self._after_write)
|
|
|
|
def _act(self, cmd: str, name: str) -> None:
|
|
_nm([cmd, name], self._after_write)
|
|
|
|
def _after_write(self, ok, d) -> None:
|
|
good = ok and isinstance(d, dict) and d.get("ok")
|
|
self._flash("Done" if good else
|
|
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
|
|
self._reload()
|
|
|
|
def _flash(self, msg: str) -> None:
|
|
self._status.set_text(msg)
|
|
self._status.set_visible(True)
|
|
|
|
|
|
class _DnsPage(Gtk.Box):
|
|
def __init__(self):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self._adapters: list[dict] = []
|
|
|
|
self.append(Gtk.Label(label="Effective servers", xalign=0.0))
|
|
self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
self.append(self._effective)
|
|
|
|
self.append(Gtk.Separator())
|
|
self.append(Gtk.Label(label="Override", xalign=0.0))
|
|
self._conn = Gtk.DropDown.new_from_strings(["…"])
|
|
self.append(_labeled("Connection", self._conn))
|
|
self._servers = _field("1.1.1.1 8.8.8.8")
|
|
self.append(_labeled("Servers", self._servers))
|
|
ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0))
|
|
self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER)
|
|
ignore.append(self._ignore)
|
|
self.append(ignore)
|
|
self.append(_pill("Apply DNS", self._apply))
|
|
self._status = Gtk.Label(xalign=0.0)
|
|
self._status.add_css_class("net-ip")
|
|
self._status.set_visible(False)
|
|
self.append(self._status)
|
|
|
|
_nm(["adapters"], self._set_adapters)
|
|
_nm(["dns"], self._show_effective)
|
|
|
|
def _set_adapters(self, ok, adapters) -> None:
|
|
self._adapters = [a for a in (adapters or []) if a.get("connection")] \
|
|
if ok and isinstance(adapters, list) else []
|
|
names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"]
|
|
self._conn.set_model(Gtk.StringList.new(names))
|
|
|
|
def _show_effective(self, ok, data) -> None:
|
|
_clear(self._effective)
|
|
if not ok or not isinstance(data, list) or not data:
|
|
self._effective.append(Gtk.Label(label="—", xalign=0.0))
|
|
return
|
|
for d in data:
|
|
self._effective.append(Gtk.Label(
|
|
label=f"{d['device']}: {', '.join(d.get('servers', []))}",
|
|
xalign=0.0, selectable=True))
|
|
|
|
def _apply(self) -> None:
|
|
idx = self._conn.get_selected()
|
|
if idx < 0 or idx >= len(self._adapters):
|
|
self._flash("Pick a connection")
|
|
return
|
|
con = self._adapters[idx]["connection"]
|
|
servers = self._servers.get_text().strip()
|
|
ign = "yes" if self._ignore.get_active() else "no"
|
|
_nm(["set-dns", con, "ipv4", servers, ign], self._after)
|
|
|
|
def _after(self, ok, d) -> None:
|
|
good = ok and isinstance(d, dict) and d.get("ok")
|
|
self._flash("Applied" if good else
|
|
f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}")
|
|
_nm(["dns"], self._show_effective)
|
|
|
|
def _flash(self, msg: str) -> None:
|
|
self._status.set_text(msg)
|
|
self._status.set_visible(True)
|
|
|
|
|
|
class _Expanded(Gtk.Box):
|
|
def __init__(self, ctx: ModuleContext):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
self.add_css_class("net-view")
|
|
self.ctx = ctx
|
|
|
|
self.stack = Gtk.Stack(vexpand=True)
|
|
switcher = Gtk.StackSwitcher(stack=self.stack)
|
|
switcher.add_css_class("net-switcher")
|
|
self.append(switcher)
|
|
self.append(self.stack)
|
|
|
|
f = ctx.feature
|
|
if f("adapters", True):
|
|
self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters")
|
|
if f("routes", True):
|
|
self.stack.add_titled(_RoutesPage(), "routes", "Routes")
|
|
if f("vlan", True):
|
|
self.stack.add_titled(_VlanPage(), "vlan", "VLAN")
|
|
if f("dns", True):
|
|
self.stack.add_titled(_DnsPage(), "dns", "DNS")
|
|
|
|
|
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
|
# Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated
|
|
# with the enabled tabs — no in-place page rebuild needed here.
|
|
compact = _Compact(ctx)
|
|
expanded = _Expanded(ctx)
|
|
return ModuleInstance(compact=compact, expanded=expanded)
|
|
|
|
|
|
SPEC = ModuleSpec(
|
|
id="network",
|
|
title="Laser Antenna Uplink",
|
|
icon="", # nf-md-lan
|
|
build=build,
|
|
default_enabled=True,
|
|
features=[Feature("adapters", "Adapters", True),
|
|
Feature("routes", "Routes", True),
|
|
Feature("vlan", "VLAN", True),
|
|
Feature("dns", "DNS", True)],
|
|
)
|