feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""Network status module.
|
||||
|
||||
Cross-platform connection info (via psutil), a WireGuard VPN status/toggle
|
||||
that only works where NetworkManager + nmcli exist (Linux — this mirrors the
|
||||
XFCE panel's network-popup.py, minus the GTK UI), and a small user-defined
|
||||
list of ping targets so "is my router/VPN endpoint reachable" isn't tied to
|
||||
any one hardcoded host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from synapse.nexus_config import RUNTIME_DIR
|
||||
|
||||
_TARGETS_FILE = RUNTIME_DIR / "network_targets.json"
|
||||
|
||||
_PING_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
|
||||
|
||||
|
||||
# --- connection info ---------------------------------------------------------------
|
||||
def hostname() -> str:
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def primary_connection() -> Dict[str, Any]:
|
||||
import psutil
|
||||
|
||||
stats = psutil.net_if_stats()
|
||||
addrs = psutil.net_if_addrs()
|
||||
for name, addr_list in addrs.items():
|
||||
st = stats.get(name)
|
||||
if not st or not st.isup:
|
||||
continue
|
||||
lname = name.lower()
|
||||
if lname.startswith(("lo", "loopback")):
|
||||
continue
|
||||
for a in addr_list:
|
||||
if a.family == socket.AF_INET and not a.address.startswith("169.254"):
|
||||
iface_type = "wifi" if any(k in lname for k in ("wlan", "wi-fi", "wireless", "wl")) else "ethernet"
|
||||
return {"interface": name, "ip": a.address, "type": iface_type}
|
||||
return {"interface": None, "ip": None, "type": "offline"}
|
||||
|
||||
|
||||
# --- WireGuard / VPN (Linux + NetworkManager only) ----------------------------------
|
||||
def _nmcli(*args: str) -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["nmcli", "-t", "--escape", "no", *args],
|
||||
text=True, stderr=subprocess.DEVNULL, timeout=5,
|
||||
).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _nmcli_available() -> bool:
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
subprocess.check_output(["nmcli", "--version"], stderr=subprocess.DEVNULL, timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def vpn_status() -> Dict[str, Any]:
|
||||
if not _nmcli_available():
|
||||
return {"available": False}
|
||||
wgs = [p for p in (line.split(":") for line
|
||||
in _nmcli("-f", "NAME,TYPE,STATE", "connection", "show").splitlines())
|
||||
if len(p) >= 3 and p[1] == "wireguard"]
|
||||
for name, _t, state in wgs:
|
||||
if state == "activated":
|
||||
return {"available": True, "configured": True, "name": name, "connected": True}
|
||||
if wgs:
|
||||
return {"available": True, "configured": True, "name": wgs[0][0], "connected": False}
|
||||
return {"available": True, "configured": False, "name": None, "connected": False}
|
||||
|
||||
|
||||
def vpn_toggle(enable: bool) -> Dict[str, Any]:
|
||||
status = vpn_status()
|
||||
if not status.get("configured"):
|
||||
raise RuntimeError("no WireGuard tunnel configured")
|
||||
action = "up" if enable else "down"
|
||||
subprocess.check_output(
|
||||
["nmcli", "connection", action, status["name"]],
|
||||
stderr=subprocess.STDOUT, text=True, timeout=15,
|
||||
)
|
||||
return vpn_status()
|
||||
|
||||
|
||||
# --- ping targets --------------------------------------------------------------------
|
||||
def _load_targets() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
return json.loads(_TARGETS_FILE.read_text()).get("targets", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _write_targets(targets: List[Dict[str, Any]]) -> None:
|
||||
_TARGETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_TARGETS_FILE.write_text(json.dumps({"targets": targets}, indent=2))
|
||||
|
||||
|
||||
def list_targets() -> List[Dict[str, Any]]:
|
||||
return _load_targets()
|
||||
|
||||
|
||||
def add_target(label: str, host: str) -> Dict[str, Any]:
|
||||
targets = _load_targets()
|
||||
t = {"id": uuid.uuid4().hex[:12], "label": label, "host": host}
|
||||
targets.append(t)
|
||||
_write_targets(targets)
|
||||
return t
|
||||
|
||||
|
||||
def delete_target(target_id: str) -> None:
|
||||
_write_targets([t for t in _load_targets() if t["id"] != target_id])
|
||||
|
||||
|
||||
def ping(host: str) -> Dict[str, Any]:
|
||||
is_windows = platform.system() == "Windows"
|
||||
cmd = ["ping", "-n", "1", "-w", "1000", host] if is_windows else ["ping", "-c", "1", "-W", "1", host]
|
||||
try:
|
||||
out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT, timeout=3)
|
||||
m = _PING_LATENCY_RE.search(out)
|
||||
return {"ok": True, "latency_ms": float(m.group(1)) if m else None}
|
||||
except subprocess.CalledProcessError:
|
||||
return {"ok": False, "latency_ms": None}
|
||||
except Exception as e:
|
||||
return {"ok": False, "latency_ms": None, "error": str(e)}
|
||||
|
||||
|
||||
def ping_targets() -> List[Dict[str, Any]]:
|
||||
return [{**t, **ping(t["host"])} for t in _load_targets()]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""HTTP routes for the network module — mounted onto the main app via modules/registry.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio as _asyncio
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from . import backend as net
|
||||
|
||||
MANIFEST = {"key": "network", "label": "Network", "icon": "📡"}
|
||||
|
||||
router = APIRouter(prefix="/network", tags=["network"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def network_status():
|
||||
return {"hostname": net.hostname(), "connection": net.primary_connection(), "vpn": net.vpn_status()}
|
||||
|
||||
|
||||
@router.post("/vpn/toggle")
|
||||
async def network_vpn_toggle(payload: Dict[str, Any] = Body(...)):
|
||||
try:
|
||||
return await _asyncio.to_thread(net.vpn_toggle, bool(payload.get("enable")))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"nmcli error: {e}")
|
||||
|
||||
|
||||
@router.get("/targets")
|
||||
async def network_targets():
|
||||
return {"targets": net.list_targets()}
|
||||
|
||||
|
||||
@router.post("/targets")
|
||||
async def network_target_create(payload: Dict[str, Any] = Body(...)):
|
||||
host = (payload.get("host") or "").strip()
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="'host' is required")
|
||||
return net.add_target((payload.get("label") or "").strip() or host, host)
|
||||
|
||||
|
||||
@router.delete("/targets/{target_id}")
|
||||
async def network_target_delete(target_id: str):
|
||||
net.delete_target(target_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def network_ping_all():
|
||||
return {"targets": await _asyncio.to_thread(net.ping_targets)}
|
||||
Reference in New Issue
Block a user