Fixed polling agent and status bars.
This commit is contained in:
parent
91e3fa13c6
commit
a88e7bbb06
14 changed files with 2131 additions and 35 deletions
574
backend/display.py
Normal file
574
backend/display.py
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
"""Atlus — X11 Display Manager.
|
||||||
|
|
||||||
|
Manages a virtual X11 display (Xvfb) with window manager (openbox),
|
||||||
|
VNC server (x11vnc), WebSocket proxy (websockify), and system tray
|
||||||
|
(stalonetray) for running native GUI apps on headless SBCs.
|
||||||
|
|
||||||
|
One shared display per user. Apps launch into the display and are
|
||||||
|
accessible via noVNC in the browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.config import DATA_DIR
|
||||||
|
|
||||||
|
log = logging.getLogger("atlus.display")
|
||||||
|
|
||||||
|
DISPLAY_DATA_DIR = DATA_DIR / "display"
|
||||||
|
|
||||||
|
# Default virtual display settings
|
||||||
|
DEFAULT_RESOLUTION = "1280x800"
|
||||||
|
DEFAULT_DEPTH = 24
|
||||||
|
XVFB_DISPLAY_BASE = 50 # Start numbering from :50 to avoid conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependency checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_bin(name: str) -> Optional[str]:
|
||||||
|
"""Find a binary, even with systemd's minimal PATH."""
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
for p in ("/usr/bin", "/usr/local/bin", "/usr/sbin", "/bin"):
|
||||||
|
full = os.path.join(p, name)
|
||||||
|
if os.path.isfile(full) and os.access(full, os.X_OK):
|
||||||
|
return full
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
BINS = {
|
||||||
|
"Xvfb": _find_bin("Xvfb"),
|
||||||
|
"openbox": _find_bin("openbox"),
|
||||||
|
"x11vnc": _find_bin("x11vnc"),
|
||||||
|
"websockify": _find_bin("websockify"),
|
||||||
|
"stalonetray": _find_bin("stalonetray"),
|
||||||
|
"wmctrl": _find_bin("wmctrl"),
|
||||||
|
"xdotool": _find_bin("xdotool"),
|
||||||
|
"xdpyinfo": _find_bin("xdpyinfo"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies() -> dict:
|
||||||
|
"""Return dependency status dict."""
|
||||||
|
return {name: (path is not None) for name, path in BINS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_display_deps():
|
||||||
|
"""Check that minimum required binaries are available."""
|
||||||
|
missing = []
|
||||||
|
for name in ("Xvfb", "openbox", "x11vnc", "websockify"):
|
||||||
|
if not BINS[name]:
|
||||||
|
missing.append(name)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Managed Process — async subprocess wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManagedProcess:
|
||||||
|
"""A long-lived subprocess with monitoring."""
|
||||||
|
name: str
|
||||||
|
proc: Optional[asyncio.subprocess.Process] = None
|
||||||
|
pid: Optional[int] = None
|
||||||
|
started_at: float = 0
|
||||||
|
_monitor_task: Optional[asyncio.Task] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.proc is not None and self.proc.returncode is None
|
||||||
|
|
||||||
|
async def start(self, *args, env=None, **kwargs):
|
||||||
|
"""Launch the subprocess."""
|
||||||
|
log.info("Starting %s: %s", self.name, " ".join(str(a) for a in args))
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.pid = self.proc.pid
|
||||||
|
self.started_at = time.time()
|
||||||
|
log.info("%s started (pid %d)", self.name, self.pid)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Terminate the process gracefully, then force-kill."""
|
||||||
|
if not self.alive:
|
||||||
|
return
|
||||||
|
if self._monitor_task and not self._monitor_task.done():
|
||||||
|
self._monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.proc.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.proc.kill()
|
||||||
|
await self.proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
log.info("%s stopped (pid %d)", self.name, self.pid)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"pid": self.pid,
|
||||||
|
"alive": self.alive,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# X11 App — a launched GUI application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class X11App:
|
||||||
|
"""A GUI application running on the virtual display."""
|
||||||
|
app_id: str
|
||||||
|
command: str
|
||||||
|
args: list[str] = field(default_factory=list)
|
||||||
|
name: str = ""
|
||||||
|
proc: Optional[asyncio.subprocess.Process] = None
|
||||||
|
pid: Optional[int] = None
|
||||||
|
started_at: float = 0
|
||||||
|
has_tray_icon: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self) -> bool:
|
||||||
|
return self.proc is not None and self.proc.returncode is None
|
||||||
|
|
||||||
|
async def start(self, env: dict):
|
||||||
|
"""Launch the application."""
|
||||||
|
cmd = [self.command] + self.args
|
||||||
|
log.info("Launching X11 app %s: %s", self.app_id, " ".join(cmd))
|
||||||
|
try:
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self.pid = self.proc.pid
|
||||||
|
self.started_at = time.time()
|
||||||
|
log.info("X11 app %s started (pid %d)", self.app_id, self.pid)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Failed to launch X11 app %s", self.app_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Terminate the application."""
|
||||||
|
if not self.alive:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.proc.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.proc.kill()
|
||||||
|
await self.proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
log.info("X11 app %s stopped", self.app_id)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"app_id": self.app_id,
|
||||||
|
"command": self.command,
|
||||||
|
"name": self.name or self.command,
|
||||||
|
"alive": self.alive,
|
||||||
|
"pid": self.pid,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"has_tray_icon": self.has_tray_icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Display Session — one virtual display per user
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DisplaySession:
|
||||||
|
"""Manages a complete virtual X11 display with all supporting services."""
|
||||||
|
|
||||||
|
def __init__(self, username: str, display_num: int):
|
||||||
|
self.username = username
|
||||||
|
self.display_num = display_num
|
||||||
|
self.display = f":{display_num}"
|
||||||
|
self.resolution = DEFAULT_RESOLUTION
|
||||||
|
self.depth = DEFAULT_DEPTH
|
||||||
|
|
||||||
|
# Managed processes
|
||||||
|
self.xvfb = ManagedProcess("Xvfb")
|
||||||
|
self.openbox = ManagedProcess("openbox")
|
||||||
|
self.x11vnc = ManagedProcess("x11vnc")
|
||||||
|
self.websockify = ManagedProcess("websockify")
|
||||||
|
self.stalonetray = ManagedProcess("stalonetray")
|
||||||
|
|
||||||
|
# Launched GUI apps
|
||||||
|
self.apps: dict[str, X11App] = {}
|
||||||
|
|
||||||
|
# VNC/WebSocket ports
|
||||||
|
self.vnc_port = 5900 + display_num
|
||||||
|
self.ws_port = 6080 + display_num
|
||||||
|
|
||||||
|
self.started = False
|
||||||
|
self.created_at = time.time()
|
||||||
|
self.last_active = time.time()
|
||||||
|
|
||||||
|
# Data directory for this session
|
||||||
|
self.session_dir = DISPLAY_DATA_DIR / username
|
||||||
|
self.session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _display_env(self) -> dict:
|
||||||
|
"""Build environment dict with DISPLAY set."""
|
||||||
|
env = {**os.environ}
|
||||||
|
env["DISPLAY"] = self.display
|
||||||
|
env["HOME"] = os.path.expanduser(f"~{self.username}") or "/"
|
||||||
|
env["USER"] = self.username
|
||||||
|
env["XAUTHORITY"] = str(self.session_dir / ".Xauthority")
|
||||||
|
# Ensure full PATH for systemd
|
||||||
|
path = env.get("PATH", "")
|
||||||
|
for p in ("/usr/local/bin", "/usr/bin", "/usr/sbin", "/bin", "/sbin"):
|
||||||
|
if p not in path:
|
||||||
|
path = p + ":" + path
|
||||||
|
env["PATH"] = path
|
||||||
|
env["LC_ALL"] = "C"
|
||||||
|
return env
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the complete display stack: Xvfb → openbox → stalonetray → x11vnc → websockify."""
|
||||||
|
if self.started:
|
||||||
|
return
|
||||||
|
|
||||||
|
env = self._display_env()
|
||||||
|
|
||||||
|
# 1. Start Xvfb
|
||||||
|
xvfb_bin = BINS["Xvfb"]
|
||||||
|
await self.xvfb.start(
|
||||||
|
xvfb_bin,
|
||||||
|
self.display,
|
||||||
|
"-screen", "0", f"{self.resolution}x{self.depth}",
|
||||||
|
"-ac", # disable access control
|
||||||
|
"-nolisten", "tcp",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for display to be ready
|
||||||
|
await self._wait_for_display(env, timeout=10)
|
||||||
|
|
||||||
|
# 2. Start openbox (window manager)
|
||||||
|
openbox_bin = BINS["openbox"]
|
||||||
|
await self.openbox.start(
|
||||||
|
openbox_bin,
|
||||||
|
"--config-file", "/dev/null",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# 3. Start stalonetray (system tray)
|
||||||
|
if BINS["stalonetray"]:
|
||||||
|
stalonetray_bin = BINS["stalonetray"]
|
||||||
|
# Configure stalonetray: small, at top-right, transparent
|
||||||
|
await self.stalonetray.start(
|
||||||
|
stalonetray_bin,
|
||||||
|
"--geometry", "1x1+0+0",
|
||||||
|
"--icon-size", "24",
|
||||||
|
"--kludges", "force_icons_size",
|
||||||
|
"--window-type", "dock",
|
||||||
|
"--grow-gravity", "E",
|
||||||
|
"--icon-gravity", "E",
|
||||||
|
"--background", "#111318",
|
||||||
|
"--no-shrink",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# 4. Start x11vnc
|
||||||
|
x11vnc_bin = BINS["x11vnc"]
|
||||||
|
await self.x11vnc.start(
|
||||||
|
x11vnc_bin,
|
||||||
|
"-display", self.display,
|
||||||
|
"-rfbport", str(self.vnc_port),
|
||||||
|
"-shared",
|
||||||
|
"-forever",
|
||||||
|
"-nopw",
|
||||||
|
"-noxdamage",
|
||||||
|
"-cursor", "most",
|
||||||
|
"-ncache", "10",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
# 5. Start websockify (WebSocket → VNC proxy)
|
||||||
|
websockify_bin = BINS["websockify"]
|
||||||
|
# Serve noVNC client files if available
|
||||||
|
novnc_dir = self._find_novnc()
|
||||||
|
ws_args = [
|
||||||
|
websockify_bin,
|
||||||
|
"--heartbeat", "30",
|
||||||
|
str(self.ws_port),
|
||||||
|
f"localhost:{self.vnc_port}",
|
||||||
|
]
|
||||||
|
if novnc_dir:
|
||||||
|
ws_args.insert(-2, "--web")
|
||||||
|
ws_args.insert(-2, str(novnc_dir))
|
||||||
|
|
||||||
|
await self.websockify.start(*ws_args, env=env)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
self.started = True
|
||||||
|
self.last_active = time.time()
|
||||||
|
log.info("Display session started for %s on %s (VNC:%d, WS:%d)",
|
||||||
|
self.username, self.display, self.vnc_port, self.ws_port)
|
||||||
|
|
||||||
|
async def _wait_for_display(self, env: dict, timeout: float = 10):
|
||||||
|
"""Wait until the Xvfb display is accepting connections."""
|
||||||
|
xdpyinfo = BINS.get("xdpyinfo")
|
||||||
|
if not xdpyinfo:
|
||||||
|
# No xdpyinfo available — just wait a bit
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
return
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
xdpyinfo, "-display", self.display,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
if proc.returncode == 0:
|
||||||
|
log.info("Display %s is ready", self.display)
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
log.warning("Display %s may not be ready after %.1fs", self.display, timeout)
|
||||||
|
|
||||||
|
def _find_novnc(self) -> Optional[Path]:
|
||||||
|
"""Find noVNC installation directory."""
|
||||||
|
candidates = [
|
||||||
|
Path("/usr/share/novnc"),
|
||||||
|
Path("/usr/share/noVNC"),
|
||||||
|
Path("/usr/local/share/novnc"),
|
||||||
|
Path("/usr/local/share/noVNC"),
|
||||||
|
Path("/opt/novnc"),
|
||||||
|
Path("/opt/noVNC"),
|
||||||
|
Path("/snap/novnc/current/usr/share/novnc"),
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if p.is_dir() and (p / "vnc.html").exists():
|
||||||
|
return p
|
||||||
|
if p.is_dir() and (p / "vnc_lite.html").exists():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop everything in reverse order."""
|
||||||
|
# Stop all apps first
|
||||||
|
for app in list(self.apps.values()):
|
||||||
|
await app.stop()
|
||||||
|
self.apps.clear()
|
||||||
|
|
||||||
|
# Stop services in reverse order
|
||||||
|
for svc in (self.websockify, self.x11vnc, self.stalonetray, self.openbox, self.xvfb):
|
||||||
|
await svc.stop()
|
||||||
|
|
||||||
|
self.started = False
|
||||||
|
log.info("Display session stopped for %s", self.username)
|
||||||
|
|
||||||
|
async def launch_app(self, command: str, args: list[str] = None,
|
||||||
|
name: str = "", has_tray_icon: bool = False) -> X11App:
|
||||||
|
"""Launch a GUI application on this display."""
|
||||||
|
if not self.started:
|
||||||
|
await self.start()
|
||||||
|
|
||||||
|
app_id = str(uuid.uuid4())[:8]
|
||||||
|
app = X11App(
|
||||||
|
app_id=app_id,
|
||||||
|
command=command,
|
||||||
|
args=args or [],
|
||||||
|
name=name or os.path.basename(command),
|
||||||
|
has_tray_icon=has_tray_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
env = self._display_env()
|
||||||
|
await app.start(env)
|
||||||
|
|
||||||
|
self.apps[app_id] = app
|
||||||
|
self.last_active = time.time()
|
||||||
|
|
||||||
|
# Start a monitor task that removes app when it exits
|
||||||
|
asyncio.create_task(self._monitor_app(app_id))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
async def _monitor_app(self, app_id: str):
|
||||||
|
"""Wait for an app to exit, then clean up."""
|
||||||
|
app = self.apps.get(app_id)
|
||||||
|
if not app or not app.proc:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await app.proc.wait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Remove from apps dict (it's already dead)
|
||||||
|
self.apps.pop(app_id, None)
|
||||||
|
log.info("X11 app %s (%s) exited", app_id, app.name)
|
||||||
|
|
||||||
|
async def stop_app(self, app_id: str) -> bool:
|
||||||
|
"""Stop a specific app."""
|
||||||
|
app = self.apps.pop(app_id, None)
|
||||||
|
if not app:
|
||||||
|
return False
|
||||||
|
await app.stop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_windows(self) -> list[dict]:
|
||||||
|
"""List windows using wmctrl."""
|
||||||
|
wmctrl = BINS.get("wmctrl")
|
||||||
|
if not wmctrl or not self.started:
|
||||||
|
return []
|
||||||
|
|
||||||
|
env = self._display_env()
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
wmctrl, "-l", "-p",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
for line in stdout.decode().strip().split("\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(None, 4)
|
||||||
|
if len(parts) >= 5:
|
||||||
|
windows.append({
|
||||||
|
"window_id": parts[0],
|
||||||
|
"desktop": parts[1],
|
||||||
|
"pid": int(parts[2]) if parts[2].isdigit() else 0,
|
||||||
|
"host": parts[3],
|
||||||
|
"title": parts[4],
|
||||||
|
})
|
||||||
|
return windows
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def focus_window(self, window_id: str) -> bool:
|
||||||
|
"""Activate a window using wmctrl."""
|
||||||
|
wmctrl = BINS.get("wmctrl")
|
||||||
|
if not wmctrl or not self.started:
|
||||||
|
return False
|
||||||
|
|
||||||
|
env = self._display_env()
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
wmctrl, "-i", "-a", window_id,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def touch(self):
|
||||||
|
self.last_active = time.time()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"username": self.username,
|
||||||
|
"display": self.display,
|
||||||
|
"resolution": self.resolution,
|
||||||
|
"vnc_port": self.vnc_port,
|
||||||
|
"ws_port": self.ws_port,
|
||||||
|
"started": self.started,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"last_active": self.last_active,
|
||||||
|
"apps": {aid: a.to_dict() for aid, a in self.apps.items() if a.alive},
|
||||||
|
"services": {
|
||||||
|
"xvfb": self.xvfb.to_dict(),
|
||||||
|
"openbox": self.openbox.to_dict(),
|
||||||
|
"x11vnc": self.x11vnc.to_dict(),
|
||||||
|
"websockify": self.websockify.to_dict(),
|
||||||
|
"stalonetray": self.stalonetray.to_dict(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Display Manager — singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DisplayManager:
|
||||||
|
"""Manages all user display sessions."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sessions: dict[str, DisplaySession] = {}
|
||||||
|
self._next_display = XVFB_DISPLAY_BASE
|
||||||
|
DISPLAY_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _allocate_display(self) -> int:
|
||||||
|
"""Allocate the next available display number."""
|
||||||
|
num = self._next_display
|
||||||
|
self._next_display += 1
|
||||||
|
return num
|
||||||
|
|
||||||
|
async def get_or_create(self, username: str) -> DisplaySession:
|
||||||
|
"""Get existing display session or create (but don't start) a new one."""
|
||||||
|
if username not in self._sessions:
|
||||||
|
display_num = self._allocate_display()
|
||||||
|
self._sessions[username] = DisplaySession(username, display_num)
|
||||||
|
log.info("Created display session for %s on :%d", username, display_num)
|
||||||
|
session = self._sessions[username]
|
||||||
|
session.touch()
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def ensure_started(self, username: str) -> DisplaySession:
|
||||||
|
"""Get or create a display session and ensure it's started."""
|
||||||
|
session = await self.get_or_create(username)
|
||||||
|
if not session.started:
|
||||||
|
await session.start()
|
||||||
|
return session
|
||||||
|
|
||||||
|
def get_session(self, username: str) -> Optional[DisplaySession]:
|
||||||
|
"""Get display session if it exists."""
|
||||||
|
return self._sessions.get(username)
|
||||||
|
|
||||||
|
async def stop_session(self, username: str) -> bool:
|
||||||
|
"""Stop and remove a display session."""
|
||||||
|
session = self._sessions.pop(username, None)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
await session.stop()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def shutdown_all(self):
|
||||||
|
"""Stop all display sessions — called on server shutdown."""
|
||||||
|
for username in list(self._sessions.keys()):
|
||||||
|
await self.stop_session(username)
|
||||||
|
log.info("All display sessions shut down")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
display_manager = DisplayManager()
|
||||||
|
|
@ -11,8 +11,9 @@ 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, network, packages, updates, session
|
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
|
||||||
from backend.sessions import manager as session_manager
|
from backend.sessions import manager as session_manager
|
||||||
|
from backend.display import display_manager
|
||||||
from backend.routers.plugins import asi_bridge
|
from backend.routers.plugins import asi_bridge
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -55,6 +56,8 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# Kill all PTYs on shutdown
|
# Kill all PTYs on shutdown
|
||||||
session_manager.shutdown_all()
|
session_manager.shutdown_all()
|
||||||
|
# Stop all X11 display sessions
|
||||||
|
await display_manager.shutdown_all()
|
||||||
log.info("Atlus shutdown complete")
|
log.info("Atlus shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,6 +112,7 @@ app.include_router(network.router)
|
||||||
app.include_router(packages.router)
|
app.include_router(packages.router)
|
||||||
app.include_router(updates.router)
|
app.include_router(updates.router)
|
||||||
app.include_router(session.router)
|
app.include_router(session.router)
|
||||||
|
app.include_router(display.router)
|
||||||
app.include_router(asi_bridge.router)
|
app.include_router(asi_bridge.router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
170
backend/routers/display.py
Normal file
170
backend/routers/display.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""X11 Display — manage virtual display, launch/control GUI apps."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.auth import get_current_user
|
||||||
|
from backend.display import display_manager, check_dependencies, _require_display_deps
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/display", tags=["display"])
|
||||||
|
log = logging.getLogger("atlus.display")
|
||||||
|
|
||||||
|
# Safe command pattern — allow typical binary paths
|
||||||
|
_SAFE_CMD = re.compile(r"^[a-zA-Z0-9_./-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LaunchAppRequest(BaseModel):
|
||||||
|
command: str # e.g. "nextcloud" or "/usr/bin/nextcloud"
|
||||||
|
args: list[str] = []
|
||||||
|
name: str = "" # friendly name
|
||||||
|
has_tray_icon: bool = False # hint: this app has a system tray icon
|
||||||
|
|
||||||
|
|
||||||
|
class FocusWindowRequest(BaseModel):
|
||||||
|
window_id: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def display_status(user: str = Depends(get_current_user)):
|
||||||
|
"""Get display session status and dependency info."""
|
||||||
|
deps = check_dependencies()
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
return {
|
||||||
|
"dependencies": deps,
|
||||||
|
"missing": _require_display_deps(),
|
||||||
|
"session": session.to_dict() if session else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_display(user: str = Depends(get_current_user)):
|
||||||
|
"""Start the virtual display session."""
|
||||||
|
missing = _require_display_deps()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
f"Missing required packages: {', '.join(missing)}. "
|
||||||
|
f"Install with: sudo apt install {' '.join(p.lower() for p in missing)}"
|
||||||
|
)
|
||||||
|
session = await display_manager.ensure_started(user)
|
||||||
|
return session.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
async def stop_display(user: str = Depends(get_current_user)):
|
||||||
|
"""Stop the virtual display session (kills all apps)."""
|
||||||
|
ok = await display_manager.stop_session(user)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "No active display session")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apps/launch")
|
||||||
|
async def launch_app(
|
||||||
|
req: LaunchAppRequest,
|
||||||
|
user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Launch a GUI application on the virtual display."""
|
||||||
|
missing = _require_display_deps()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(503, f"Missing required packages: {', '.join(missing)}")
|
||||||
|
|
||||||
|
# Validate command (prevent injection)
|
||||||
|
if not _SAFE_CMD.match(req.command):
|
||||||
|
raise HTTPException(400, "Invalid command — only alphanumeric, dots, dashes, slashes allowed")
|
||||||
|
|
||||||
|
# Validate args too
|
||||||
|
for arg in req.args:
|
||||||
|
if arg.startswith("-"):
|
||||||
|
continue # flags are OK
|
||||||
|
if not _SAFE_CMD.match(arg):
|
||||||
|
raise HTTPException(400, f"Invalid argument: {arg}")
|
||||||
|
|
||||||
|
session = await display_manager.ensure_started(user)
|
||||||
|
try:
|
||||||
|
app = await session.launch_app(
|
||||||
|
command=req.command,
|
||||||
|
args=req.args,
|
||||||
|
name=req.name,
|
||||||
|
has_tray_icon=req.has_tray_icon,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Failed to launch app: {e}")
|
||||||
|
|
||||||
|
return app.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/apps")
|
||||||
|
async def list_apps(user: str = Depends(get_current_user)):
|
||||||
|
"""List running GUI apps."""
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
if not session:
|
||||||
|
return {"apps": []}
|
||||||
|
# Prune dead apps
|
||||||
|
dead = [aid for aid, a in session.apps.items() if not a.alive]
|
||||||
|
for aid in dead:
|
||||||
|
session.apps.pop(aid, None)
|
||||||
|
return {"apps": [a.to_dict() for a in session.apps.values()]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/apps/{app_id}")
|
||||||
|
async def stop_app(app_id: str, user: str = Depends(get_current_user)):
|
||||||
|
"""Stop a specific GUI app."""
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(404, "No active display session")
|
||||||
|
ok = await session.stop_app(app_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "App not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/windows")
|
||||||
|
async def list_windows(user: str = Depends(get_current_user)):
|
||||||
|
"""List X11 windows via wmctrl."""
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
if not session or not session.started:
|
||||||
|
return {"windows": []}
|
||||||
|
windows = await session.list_windows()
|
||||||
|
return {"windows": windows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/windows/focus")
|
||||||
|
async def focus_window(
|
||||||
|
req: FocusWindowRequest,
|
||||||
|
user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Focus/activate a specific window."""
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
if not session or not session.started:
|
||||||
|
raise HTTPException(404, "No active display session")
|
||||||
|
ok = await session.focus_window(req.window_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, "Failed to focus window")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect")
|
||||||
|
async def get_connection_info(user: str = Depends(get_current_user)):
|
||||||
|
"""Get WebSocket connection info for noVNC."""
|
||||||
|
session = display_manager.get_session(user)
|
||||||
|
if not session or not session.started:
|
||||||
|
raise HTTPException(404, "No active display session — start one first")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ws_port": session.ws_port,
|
||||||
|
"display": session.display,
|
||||||
|
"resolution": session.resolution,
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Network configuration via NetworkManager (nmcli)."""
|
"""Network configuration via NetworkManager (nmcli) or direct ip/interfaces."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
|
@ -6,6 +6,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
@ -16,7 +17,26 @@ from backend.auth import get_current_user
|
||||||
router = APIRouter(prefix="/api/network", tags=["network"])
|
router = APIRouter(prefix="/api/network", tags=["network"])
|
||||||
log = logging.getLogger("atlus.network")
|
log = logging.getLogger("atlus.network")
|
||||||
|
|
||||||
_HAS_NMCLI = bool(shutil.which("nmcli"))
|
|
||||||
|
def _find_bin(name: str) -> Optional[str]:
|
||||||
|
"""Find a binary, even with systemd's minimal PATH."""
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
for p in ("/usr/bin", "/usr/local/bin", "/usr/sbin", "/sbin", "/bin"):
|
||||||
|
full = os.path.join(p, name)
|
||||||
|
if os.path.isfile(full) and os.access(full, os.X_OK):
|
||||||
|
return full
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_NMCLI = bool(_find_bin("nmcli"))
|
||||||
|
_IP_BIN = _find_bin("ip") or "ip"
|
||||||
|
|
||||||
|
# Debian/Armbian network config paths
|
||||||
|
_INTERFACES_FILE = Path("/etc/network/interfaces")
|
||||||
|
_INTERFACES_DIR = Path("/etc/network/interfaces.d")
|
||||||
|
_RESOLV_CONF = Path("/etc/resolv.conf")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -354,3 +374,415 @@ async def apply_connection(name: str, _user: str = Depends(get_current_user)):
|
||||||
"""Reapply/reconnect a connection."""
|
"""Reapply/reconnect a connection."""
|
||||||
await _nmcli("connection", "up", name, timeout=15)
|
await _nmcli("connection", "up", name, timeout=15)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Fallback: direct `ip` + /etc/network/interfaces (no NetworkManager)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
_IFACE_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_iface_name(name: str):
|
||||||
|
"""Prevent injection via interface name."""
|
||||||
|
if not _IFACE_NAME_RE.match(name) or len(name) > 32:
|
||||||
|
raise HTTPException(400, "Invalid interface name")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_cmd(*args: str, timeout: float = 15) -> tuple[int, str, str]:
|
||||||
|
"""Run a command with safe env, return (rc, stdout, stderr)."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=_safe_env(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return 1, "", "Command timed out"
|
||||||
|
return proc.returncode, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_ip_addr() -> list[dict]:
|
||||||
|
"""Parse `ip -j addr show` into a list of interface dicts."""
|
||||||
|
rc, stdout, _ = await _run_cmd(_IP_BIN, "-j", "addr", "show")
|
||||||
|
if rc != 0 or not stdout.strip():
|
||||||
|
# Fallback: parse non-json output
|
||||||
|
return await _parse_ip_addr_text()
|
||||||
|
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
ifaces = json.loads(stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return await _parse_ip_addr_text()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for iface in ifaces:
|
||||||
|
name = iface.get("ifname", "")
|
||||||
|
if name == "lo":
|
||||||
|
continue
|
||||||
|
state = iface.get("operstate", "UNKNOWN").upper()
|
||||||
|
flags = iface.get("flags", [])
|
||||||
|
mac = iface.get("address", "")
|
||||||
|
|
||||||
|
ipv4 = ""
|
||||||
|
ipv6 = ""
|
||||||
|
gateway = ""
|
||||||
|
for ai in iface.get("addr_info", []):
|
||||||
|
if ai.get("family") == "inet" and not ipv4:
|
||||||
|
ipv4 = f"{ai['local']}/{ai.get('prefixlen', 24)}"
|
||||||
|
elif ai.get("family") == "inet6" and not ipv6:
|
||||||
|
if not ai.get("local", "").startswith("fe80"):
|
||||||
|
ipv6 = ai.get("local", "")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"name": name,
|
||||||
|
"state": state,
|
||||||
|
"up": state == "UP" or "UP" in flags,
|
||||||
|
"mac": mac,
|
||||||
|
"ipv4": ipv4,
|
||||||
|
"ipv6": ipv6,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get default gateway
|
||||||
|
rc2, gw_out, _ = await _run_cmd(_IP_BIN, "-j", "route", "show", "default")
|
||||||
|
if rc2 == 0 and gw_out.strip():
|
||||||
|
try:
|
||||||
|
routes = json.loads(gw_out)
|
||||||
|
for route in routes:
|
||||||
|
gw = route.get("gateway", "")
|
||||||
|
dev = route.get("dev", "")
|
||||||
|
for iface in results:
|
||||||
|
if iface["name"] == dev:
|
||||||
|
iface["gateway"] = gw
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_ip_addr_text() -> list[dict]:
|
||||||
|
"""Fallback parser for `ip addr show` plain text output."""
|
||||||
|
rc, stdout, _ = await _run_cmd(_IP_BIN, "addr", "show")
|
||||||
|
if rc != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
current = None
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
# New interface line: "2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ..."
|
||||||
|
m = re.match(r"^\d+:\s+(\S+?):\s+<([^>]*)>", line)
|
||||||
|
if m:
|
||||||
|
if current:
|
||||||
|
results.append(current)
|
||||||
|
name = m.group(1).rstrip(":")
|
||||||
|
flags = m.group(2)
|
||||||
|
if name == "lo":
|
||||||
|
current = None
|
||||||
|
continue
|
||||||
|
current = {
|
||||||
|
"name": name,
|
||||||
|
"state": "UP" if "UP" in flags else "DOWN",
|
||||||
|
"up": "UP" in flags,
|
||||||
|
"mac": "",
|
||||||
|
"ipv4": "",
|
||||||
|
"ipv6": "",
|
||||||
|
"gateway": "",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# MAC address: " link/ether aa:bb:cc:dd:ee:ff ..."
|
||||||
|
m = re.match(r"\s+link/ether\s+(\S+)", line)
|
||||||
|
if m:
|
||||||
|
current["mac"] = m.group(1)
|
||||||
|
|
||||||
|
# IPv4: " inet 192.168.1.100/24 ..."
|
||||||
|
m = re.match(r"\s+inet\s+(\S+)", line)
|
||||||
|
if m and not current["ipv4"]:
|
||||||
|
current["ipv4"] = m.group(1)
|
||||||
|
|
||||||
|
# IPv6: " inet6 2001:db8::1/64 ..."
|
||||||
|
m = re.match(r"\s+inet6\s+(\S+)", line)
|
||||||
|
if m and not current["ipv6"]:
|
||||||
|
addr = m.group(1).split("/")[0]
|
||||||
|
if not addr.startswith("fe80"):
|
||||||
|
current["ipv6"] = addr
|
||||||
|
|
||||||
|
if current:
|
||||||
|
results.append(current)
|
||||||
|
|
||||||
|
# Get default gateway
|
||||||
|
rc2, gw_out, _ = await _run_cmd(_IP_BIN, "route", "show", "default")
|
||||||
|
if rc2 == 0:
|
||||||
|
for line in gw_out.splitlines():
|
||||||
|
m = re.match(r"default via (\S+) dev (\S+)", line)
|
||||||
|
if m:
|
||||||
|
for iface in results:
|
||||||
|
if iface["name"] == m.group(2):
|
||||||
|
iface["gateway"] = m.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_interfaces_file() -> dict[str, dict]:
|
||||||
|
"""Parse /etc/network/interfaces into per-iface config dicts."""
|
||||||
|
configs = {}
|
||||||
|
# Also include files from interfaces.d
|
||||||
|
content = ""
|
||||||
|
if _INTERFACES_FILE.exists():
|
||||||
|
content = _INTERFACES_FILE.read_text()
|
||||||
|
# Check for source lines
|
||||||
|
for line in content.splitlines():
|
||||||
|
m = re.match(r"^source\s+(.+)", line)
|
||||||
|
if m:
|
||||||
|
pattern = m.group(1).strip()
|
||||||
|
from glob import glob as gglob
|
||||||
|
for path in gglob(pattern):
|
||||||
|
try:
|
||||||
|
content += "\n" + Path(path).read_text()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Also read interfaces.d/*.cfg and interfaces.d/* files
|
||||||
|
if _INTERFACES_DIR.is_dir():
|
||||||
|
for f in sorted(_INTERFACES_DIR.iterdir()):
|
||||||
|
if f.is_file():
|
||||||
|
try:
|
||||||
|
content += "\n" + f.read_text()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_iface = None
|
||||||
|
for line in content.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
# "auto eth0" or "allow-hotplug eth0"
|
||||||
|
m = re.match(r"^(auto|allow-hotplug)\s+(\S+)", stripped)
|
||||||
|
if m:
|
||||||
|
iface_name = m.group(2)
|
||||||
|
if iface_name not in configs:
|
||||||
|
configs[iface_name] = {"auto": False, "method": "dhcp"}
|
||||||
|
if m.group(1) == "auto":
|
||||||
|
configs[iface_name]["auto"] = True
|
||||||
|
continue
|
||||||
|
# "iface eth0 inet static"
|
||||||
|
m = re.match(r"^iface\s+(\S+)\s+inet\s+(\S+)", stripped)
|
||||||
|
if m:
|
||||||
|
iface_name = m.group(1)
|
||||||
|
method = m.group(2) # static, dhcp, manual, loopback
|
||||||
|
if iface_name not in configs:
|
||||||
|
configs[iface_name] = {"auto": False}
|
||||||
|
configs[iface_name]["method"] = method
|
||||||
|
current_iface = iface_name
|
||||||
|
continue
|
||||||
|
# Indented config lines under an iface block
|
||||||
|
if current_iface and line[0] in (" ", "\t"):
|
||||||
|
m = re.match(r"\s+(address|netmask|gateway|dns-nameservers|network|broadcast)\s+(.+)", stripped)
|
||||||
|
if m:
|
||||||
|
key = m.group(1)
|
||||||
|
val = m.group(2).strip()
|
||||||
|
configs[current_iface][key] = val
|
||||||
|
else:
|
||||||
|
current_iface = None
|
||||||
|
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
|
def _write_interface_config(iface: str, method: str, address: str = "",
|
||||||
|
gateway: str = "", dns: list[str] = None):
|
||||||
|
"""Write or update an interface block in /etc/network/interfaces.d/{iface}.
|
||||||
|
|
||||||
|
Uses interfaces.d to avoid clobbering the main file.
|
||||||
|
"""
|
||||||
|
_INTERFACES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if method == "dhcp":
|
||||||
|
content = f"""# Managed by Atlus
|
||||||
|
auto {iface}
|
||||||
|
iface {iface} inet dhcp
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
# Parse CIDR to address + netmask
|
||||||
|
try:
|
||||||
|
net_if = ipaddress.ip_interface(address)
|
||||||
|
addr = str(net_if.ip)
|
||||||
|
mask = str(net_if.netmask)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(422, f"Invalid address: {address}")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# Managed by Atlus",
|
||||||
|
f"auto {iface}",
|
||||||
|
f"iface {iface} inet static",
|
||||||
|
f" address {addr}",
|
||||||
|
f" netmask {mask}",
|
||||||
|
]
|
||||||
|
if gateway:
|
||||||
|
lines.append(f" gateway {gateway}")
|
||||||
|
if dns:
|
||||||
|
lines.append(f" dns-nameservers {' '.join(dns)}")
|
||||||
|
content = "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
config_file = _INTERFACES_DIR / iface
|
||||||
|
config_file.write_text(content)
|
||||||
|
log.info("Wrote interface config for %s: method=%s", iface, method)
|
||||||
|
|
||||||
|
# Also update resolv.conf for static DNS
|
||||||
|
if method == "static" and dns:
|
||||||
|
_write_resolv_conf(dns)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_resolv_conf(dns: list[str]):
|
||||||
|
"""Write DNS servers to /etc/resolv.conf."""
|
||||||
|
try:
|
||||||
|
lines = ["# Managed by Atlus\n"]
|
||||||
|
for d in dns:
|
||||||
|
lines.append(f"nameserver {d}\n")
|
||||||
|
_RESOLV_CONF.write_text("".join(lines))
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("Failed to write resolv.conf: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fallback endpoints ---
|
||||||
|
|
||||||
|
@router.get("/interfaces")
|
||||||
|
async def list_interfaces(_user: str = Depends(get_current_user)):
|
||||||
|
"""List network interfaces using ip commands (no NetworkManager required)."""
|
||||||
|
ifaces = await _parse_ip_addr()
|
||||||
|
|
||||||
|
# Merge with /etc/network/interfaces config
|
||||||
|
configs = _parse_interfaces_file()
|
||||||
|
for iface in ifaces:
|
||||||
|
cfg = configs.get(iface["name"], {})
|
||||||
|
iface["config_method"] = cfg.get("method", "unknown")
|
||||||
|
iface["config_address"] = cfg.get("address", "")
|
||||||
|
iface["config_netmask"] = cfg.get("netmask", "")
|
||||||
|
iface["config_gateway"] = cfg.get("gateway", "")
|
||||||
|
iface["config_dns"] = cfg.get("dns-nameservers", "")
|
||||||
|
|
||||||
|
# Get DNS from resolv.conf
|
||||||
|
dns_servers = []
|
||||||
|
if _RESOLV_CONF.exists():
|
||||||
|
try:
|
||||||
|
for line in _RESOLV_CONF.read_text().splitlines():
|
||||||
|
m = re.match(r"^nameserver\s+(\S+)", line.strip())
|
||||||
|
if m:
|
||||||
|
dns_servers.append(m.group(1))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"interfaces": ifaces,
|
||||||
|
"dns": dns_servers,
|
||||||
|
"has_nmcli": _HAS_NMCLI,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceConfig(BaseModel):
|
||||||
|
method: Literal["dhcp", "static"]
|
||||||
|
address: Optional[str] = None # CIDR e.g. "192.168.1.100/24"
|
||||||
|
gateway: Optional[str] = None
|
||||||
|
dns: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/interfaces/{name}/config")
|
||||||
|
async def configure_interface(
|
||||||
|
name: str,
|
||||||
|
cfg: InterfaceConfig,
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Configure an interface in /etc/network/interfaces.d/ and apply."""
|
||||||
|
_validate_iface_name(name)
|
||||||
|
|
||||||
|
if cfg.method == "static":
|
||||||
|
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_ipv4_config(IPv4Config(method="manual", address=cfg.address,
|
||||||
|
gateway=cfg.gateway, dns=cfg.dns))
|
||||||
|
|
||||||
|
# Write config file
|
||||||
|
_write_interface_config(
|
||||||
|
iface=name,
|
||||||
|
method=cfg.method,
|
||||||
|
address=cfg.address or "",
|
||||||
|
gateway=cfg.gateway or "",
|
||||||
|
dns=cfg.dns,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply: bring interface down then up
|
||||||
|
# Use ifdown/ifup if available, else ip commands
|
||||||
|
ifdown = _find_bin("ifdown")
|
||||||
|
ifup = _find_bin("ifup")
|
||||||
|
|
||||||
|
if ifdown and ifup:
|
||||||
|
await _run_cmd(ifdown, name, timeout=10)
|
||||||
|
rc, _, stderr = await _run_cmd(ifup, name, timeout=15)
|
||||||
|
if rc != 0:
|
||||||
|
log.warning("ifup %s failed: %s", name, stderr.strip())
|
||||||
|
# Try ip commands as fallback
|
||||||
|
await _apply_with_ip(name, cfg)
|
||||||
|
else:
|
||||||
|
await _apply_with_ip(name, cfg)
|
||||||
|
|
||||||
|
return {"success": True, "method": cfg.method}
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_with_ip(name: str, cfg: InterfaceConfig):
|
||||||
|
"""Apply network config using ip commands directly."""
|
||||||
|
# Flush existing addresses
|
||||||
|
await _run_cmd(_IP_BIN, "addr", "flush", "dev", name)
|
||||||
|
|
||||||
|
if cfg.method == "static" and cfg.address:
|
||||||
|
# Add address
|
||||||
|
await _run_cmd(_IP_BIN, "addr", "add", cfg.address, "dev", name)
|
||||||
|
# Bring up
|
||||||
|
await _run_cmd(_IP_BIN, "link", "set", name, "up")
|
||||||
|
# Set default route
|
||||||
|
if cfg.gateway:
|
||||||
|
await _run_cmd(_IP_BIN, "route", "del", "default", "dev", name)
|
||||||
|
await _run_cmd(_IP_BIN, "route", "add", "default", "via", cfg.gateway, "dev", name)
|
||||||
|
# Set DNS
|
||||||
|
if cfg.dns:
|
||||||
|
_write_resolv_conf(cfg.dns)
|
||||||
|
else:
|
||||||
|
# DHCP — try dhclient or udhcpc
|
||||||
|
dhclient = _find_bin("dhclient")
|
||||||
|
udhcpc = _find_bin("udhcpc")
|
||||||
|
if dhclient:
|
||||||
|
await _run_cmd(dhclient, "-r", name, timeout=5)
|
||||||
|
await _run_cmd(dhclient, name, timeout=15)
|
||||||
|
elif udhcpc:
|
||||||
|
await _run_cmd(udhcpc, "-i", name, "-n", timeout=15)
|
||||||
|
else:
|
||||||
|
await _run_cmd(_IP_BIN, "link", "set", name, "up")
|
||||||
|
log.warning("No DHCP client found — interface %s brought up without DHCP", name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interfaces/{name}/up")
|
||||||
|
async def interface_up(name: str, _user: str = Depends(get_current_user)):
|
||||||
|
"""Bring an interface up."""
|
||||||
|
_validate_iface_name(name)
|
||||||
|
rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "up")
|
||||||
|
if rc != 0:
|
||||||
|
raise HTTPException(500, f"Failed to bring up {name}: {stderr.strip()}")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interfaces/{name}/down")
|
||||||
|
async def interface_down(name: str, _user: str = Depends(get_current_user)):
|
||||||
|
"""Bring an interface down."""
|
||||||
|
_validate_iface_name(name)
|
||||||
|
rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "down")
|
||||||
|
if rc != 0:
|
||||||
|
raise HTTPException(500, f"Failed to bring down {name}: {stderr.strip()}")
|
||||||
|
return {"success": True}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Self-update — check Gitea repo for new commits and apply updates."""
|
"""Self-update — check Gitea repo for new commits and apply updates."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ from backend.auth import get_current_user
|
||||||
from backend.config import BASE_DIR
|
from backend.config import BASE_DIR
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
||||||
|
log = logging.getLogger("atlus.updates")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Guard
|
# Guard
|
||||||
|
|
@ -18,18 +20,19 @@ router = APIRouter(prefix="/api/updates", tags=["updates"])
|
||||||
_IS_GIT = (BASE_DIR / ".git").is_dir()
|
_IS_GIT = (BASE_DIR / ".git").is_dir()
|
||||||
|
|
||||||
|
|
||||||
def _find_git() -> bool:
|
def _find_git() -> str | None:
|
||||||
"""Check if git is available, even with systemd's minimal PATH."""
|
"""Find the git binary, even with systemd's minimal PATH."""
|
||||||
if shutil.which("git"):
|
found = shutil.which("git")
|
||||||
return True
|
if found:
|
||||||
# systemd services often have a stripped PATH — check common locations
|
return found
|
||||||
for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"):
|
for p in ("/usr/bin/git", "/usr/local/bin/git", "/bin/git"):
|
||||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||||
return True
|
return p
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
|
||||||
_HAS_GIT = _find_git()
|
_GIT_BIN = _find_git()
|
||||||
|
_HAS_GIT = _GIT_BIN is not None
|
||||||
|
|
||||||
|
|
||||||
def _require_git():
|
def _require_git():
|
||||||
|
|
@ -51,13 +54,21 @@ def _safe_env():
|
||||||
if p not in path:
|
if p not in path:
|
||||||
path = p + ":" + path
|
path = p + ":" + path
|
||||||
env["PATH"] = path
|
env["PATH"] = path
|
||||||
|
# Ensure HOME is set — systemd may strip it, git needs it for SSH keys
|
||||||
|
if "HOME" not in env:
|
||||||
|
import pwd
|
||||||
|
try:
|
||||||
|
env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir
|
||||||
|
except KeyError:
|
||||||
|
env["HOME"] = "/root"
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
async def _git(*args: str, timeout: float = 30) -> str:
|
async def _git(*args: str, timeout: float = 30) -> str:
|
||||||
"""Run a git command in the Atlus install directory."""
|
"""Run a git command in the Atlus install directory."""
|
||||||
_require_git()
|
_require_git()
|
||||||
cmd = ["git", "-C", str(BASE_DIR)] + list(args)
|
cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args)
|
||||||
|
log.debug("Running: %s", " ".join(cmd))
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
|
@ -72,6 +83,7 @@ async def _git(*args: str, timeout: float = 30) -> str:
|
||||||
raise HTTPException(504, "git operation timed out")
|
raise HTTPException(504, "git operation timed out")
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
msg = stderr.decode().strip() or stdout.decode().strip()
|
msg = stderr.decode().strip() or stdout.decode().strip()
|
||||||
|
log.warning("git %s failed (rc=%d): %s", args[0] if args else "?", proc.returncode, msg)
|
||||||
raise HTTPException(500, f"git error: {msg}")
|
raise HTTPException(500, f"git error: {msg}")
|
||||||
return stdout.decode().strip()
|
return stdout.decode().strip()
|
||||||
|
|
||||||
|
|
@ -79,7 +91,7 @@ async def _git(*args: str, timeout: float = 30) -> str:
|
||||||
async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
|
async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
|
||||||
"""Run a git command, return (returncode, stdout) without raising."""
|
"""Run a git command, return (returncode, stdout) without raising."""
|
||||||
_require_git()
|
_require_git()
|
||||||
cmd = ["git", "-C", str(BASE_DIR)] + list(args)
|
cmd = [_GIT_BIN, "-C", str(BASE_DIR)] + list(args)
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
|
@ -92,6 +104,9 @@ async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
return 1, ""
|
return 1, ""
|
||||||
|
if proc.returncode != 0:
|
||||||
|
log.debug("git %s returned %d: %s", args[0] if args else "?",
|
||||||
|
proc.returncode, stderr.decode().strip())
|
||||||
return proc.returncode, stdout.decode().strip()
|
return proc.returncode, stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,14 +123,16 @@ async def check_for_updates(_user: str = Depends(get_current_user)):
|
||||||
# Fetch latest from remote (may take a few seconds)
|
# Fetch latest from remote (may take a few seconds)
|
||||||
try:
|
try:
|
||||||
await _git("fetch", "origin", timeout=30)
|
await _git("fetch", "origin", timeout=30)
|
||||||
except HTTPException:
|
except HTTPException as e:
|
||||||
# Fetch failed (no network, etc.) — report no update
|
# Fetch failed (no network, no auth, etc.)
|
||||||
|
error_msg = str(e.detail) if hasattr(e, 'detail') else str(e)
|
||||||
|
log.warning("git fetch failed: %s", error_msg)
|
||||||
return {
|
return {
|
||||||
"available": False,
|
"available": False,
|
||||||
"local_hash": local_hash[:8],
|
"local_hash": local_hash[:8],
|
||||||
"remote_hash": local_hash[:8],
|
"remote_hash": local_hash[:8],
|
||||||
"behind_count": 0,
|
"behind_count": 0,
|
||||||
"error": "Could not reach remote repository",
|
"error": f"Could not reach remote: {error_msg}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get remote HEAD
|
# Get remote HEAD
|
||||||
|
|
|
||||||
|
|
@ -149,3 +149,18 @@
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svc-pin-btn {
|
||||||
|
opacity: 0.35;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-pin-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-pin-btn.pinned {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
|
||||||
185
frontend/css/apps/xdisplay.css
Normal file
185
frontend/css/apps/xdisplay.css
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
/* X11 Display viewer */
|
||||||
|
|
||||||
|
.app-xdisplay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.xdisplay-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-structural);
|
||||||
|
min-height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-component);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-connect-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-connect-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Launch dropdown */
|
||||||
|
.xdisplay-launch-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-launch-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-component);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-launch-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-launch-menu-item:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running apps list */
|
||||||
|
.xdisplay-app-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-app-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-structural);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-app-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-app-stop {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-app-stop:hover {
|
||||||
|
background: var(--status-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-no-apps {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewer area */
|
||||||
|
.xdisplay-viewer {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
background: #0d0f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status messages */
|
||||||
|
.xdisplay-status {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-status.info {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-status.error {
|
||||||
|
color: var(--status-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xdisplay-status.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
@ -282,3 +282,68 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop Apps (tray) */
|
||||||
|
.panel-tray-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-dot.running {
|
||||||
|
background: var(--status-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-dot.stopped {
|
||||||
|
background: var(--status-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-name {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-name:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-open {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tray-open:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
<link rel="stylesheet" href="/css/apps/settings.css">
|
<link rel="stylesheet" href="/css/apps/settings.css">
|
||||||
<link rel="stylesheet" href="/css/apps/packages.css">
|
<link rel="stylesheet" href="/css/apps/packages.css">
|
||||||
<link rel="stylesheet" href="/css/apps/editor.css">
|
<link rel="stylesheet" href="/css/apps/editor.css">
|
||||||
|
<link rel="stylesheet" href="/css/apps/xdisplay.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- ================================================================= -->
|
<!-- ================================================================= -->
|
||||||
|
|
@ -60,6 +61,10 @@
|
||||||
<span class="dock-icon">📦</span>
|
<span class="dock-icon">📦</span>
|
||||||
<span class="dock-label">Packages</span>
|
<span class="dock-label">Packages</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="dock-item" data-app="xdisplay">
|
||||||
|
<span class="dock-icon">🖥</span>
|
||||||
|
<span class="dock-label">Display</span>
|
||||||
|
</button>
|
||||||
<div class="dock-separator"></div>
|
<div class="dock-separator"></div>
|
||||||
<button class="dock-item" data-app="asi-bridge">
|
<button class="dock-item" data-app="asi-bridge">
|
||||||
<span class="dock-icon">🔭</span>
|
<span class="dock-icon">🔭</span>
|
||||||
|
|
@ -140,6 +145,12 @@
|
||||||
<!-- Update notification -->
|
<!-- Update notification -->
|
||||||
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
|
<div class="panel-section panel-updates hidden" id="panelUpdates"></div>
|
||||||
|
|
||||||
|
<!-- Desktop Apps (tray) -->
|
||||||
|
<div class="panel-section panel-tray hidden" id="panelTray">
|
||||||
|
<div class="panel-section-title">DESKTOP APPS</div>
|
||||||
|
<div id="panelTrayApps" class="panel-tray-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Network -->
|
<!-- Network -->
|
||||||
<div class="panel-section panel-network">
|
<div class="panel-section panel-network">
|
||||||
<div class="panel-section-title">NETWORK</div>
|
<div class="panel-section-title">NETWORK</div>
|
||||||
|
|
@ -207,6 +218,7 @@
|
||||||
<script src="/js/apps/settings.js"></script>
|
<script src="/js/apps/settings.js"></script>
|
||||||
<script src="/js/apps/packages.js"></script>
|
<script src="/js/apps/packages.js"></script>
|
||||||
<script src="/js/apps/editor.js"></script>
|
<script src="/js/apps/editor.js"></script>
|
||||||
|
<script src="/js/apps/xdisplay.js"></script>
|
||||||
<script src="/js/apps/asi_bridge.js"></script>
|
<script src="/js/apps/asi_bridge.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,33 @@
|
||||||
let searchInput = null;
|
let searchInput = null;
|
||||||
let allServices = [];
|
let allServices = [];
|
||||||
let filterActive = false;
|
let filterActive = false;
|
||||||
|
let pinnedServices = []; // unit names pinned to right panel
|
||||||
|
|
||||||
|
async function loadPinnedServices() {
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/settings');
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const cfg = await res.json();
|
||||||
|
pinnedServices = cfg.panel_services || [];
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePin(unit) {
|
||||||
|
const idx = pinnedServices.indexOf(unit);
|
||||||
|
if (idx >= 0) {
|
||||||
|
pinnedServices.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
pinnedServices.push(unit);
|
||||||
|
}
|
||||||
|
// Save to settings
|
||||||
|
try {
|
||||||
|
await Atlus.apiFetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { panel_services: pinnedServices },
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
renderServices();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadServices() {
|
async function loadServices() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -48,6 +75,8 @@
|
||||||
const stateClass = svc.active === 'active' ? 'active' :
|
const stateClass = svc.active === 'active' ? 'active' :
|
||||||
svc.active === 'failed' ? 'failed' : 'inactive';
|
svc.active === 'failed' ? 'failed' : 'inactive';
|
||||||
|
|
||||||
|
const isPinned = pinnedServices.includes(svc.unit);
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="svc-toggle">
|
<div class="svc-toggle">
|
||||||
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button>
|
<button class="service-toggle ${isActive ? 'on' : ''}" data-unit="${svc.unit}"></button>
|
||||||
|
|
@ -59,6 +88,7 @@
|
||||||
<span class="svc-state ${stateClass}">${svc.active}</span>
|
<span class="svc-state ${stateClass}">${svc.active}</span>
|
||||||
<span class="svc-sub">${svc.sub}</span>
|
<span class="svc-sub">${svc.sub}</span>
|
||||||
<div class="svc-actions">
|
<div class="svc-actions">
|
||||||
|
<button class="svc-action-btn svc-pin-btn ${isPinned ? 'pinned' : ''}" data-unit="${svc.unit}" title="${isPinned ? 'Unpin from panel' : 'Pin to panel'}">📌</button>
|
||||||
<button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart">↻</button>
|
<button class="svc-action-btn" data-unit="${svc.unit}" data-action="restart" title="Restart">↻</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -84,6 +114,10 @@
|
||||||
loadServices();
|
loadServices();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pin handler
|
||||||
|
const pinBtn = row.querySelector('.svc-pin-btn');
|
||||||
|
pinBtn.addEventListener('click', () => togglePin(svc.unit));
|
||||||
|
|
||||||
listEl.appendChild(row);
|
listEl.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +157,7 @@
|
||||||
listEl.className = 'services-list';
|
listEl.className = 'services-list';
|
||||||
container.appendChild(listEl);
|
container.appendChild(listEl);
|
||||||
|
|
||||||
loadServices();
|
loadPinnedServices().then(() => loadServices());
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
|
@ -132,6 +166,7 @@
|
||||||
searchInput = null;
|
searchInput = null;
|
||||||
allServices = [];
|
allServices = [];
|
||||||
filterActive = false;
|
filterActive = false;
|
||||||
|
pinnedServices = [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,35 +252,220 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _renderNetworkReadonly() {
|
async function _renderNetworkReadonly() {
|
||||||
// Fallback: read-only view from psutil stats
|
// Fallback: use /api/network/interfaces (ip commands, no NetworkManager)
|
||||||
const res = await Atlus.apiFetch('/api/stats');
|
let ifacesRes;
|
||||||
const data = await res.json();
|
try {
|
||||||
|
ifacesRes = await Atlus.apiFetch('/api/network/interfaces');
|
||||||
|
} catch (e) {
|
||||||
|
ifacesRes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ifacesRes || !ifacesRes.ok) {
|
||||||
|
// Final fallback: read-only psutil view
|
||||||
|
const res = await Atlus.apiFetch('/api/stats');
|
||||||
|
const data = await res.json();
|
||||||
|
let html = '<div class="settings-section-title">Network</div>';
|
||||||
|
const ifaces = data.network.interfaces;
|
||||||
|
for (const [name, info] of Object.entries(ifaces)) {
|
||||||
|
html += `
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">${name.toUpperCase()}</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row-label">Status</div>
|
||||||
|
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row-label">IPv4</div>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
contentEl.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ifData = await ifacesRes.json();
|
||||||
|
const ifaces = ifData.interfaces || [];
|
||||||
|
const dnsServers = ifData.dns || [];
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
for (const [name, info] of Object.entries(ifaces)) {
|
// Per-interface cards
|
||||||
|
for (const iface of ifaces) {
|
||||||
|
const isUp = iface.up;
|
||||||
|
const dotColor = isUp ? 'var(--status-green)' : 'var(--status-red)';
|
||||||
|
const ipDisplay = iface.ipv4 ? iface.ipv4.split('/')[0] : '--';
|
||||||
|
const cidr = iface.ipv4 || '';
|
||||||
|
const method = iface.config_method || 'unknown';
|
||||||
|
const isStatic = method === 'static';
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="settings-group">
|
<div class="settings-group" id="ifaceGroup-${iface.name}">
|
||||||
<div class="settings-group-title">${name.toUpperCase()}</div>
|
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px;">
|
||||||
<div class="settings-row">
|
<div style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;"></div>
|
||||||
<div class="settings-row-label">Status</div>
|
${iface.name.toUpperCase()}
|
||||||
<span style="color:${info.up ? 'var(--status-green)' : 'var(--status-red)'};">${info.up ? 'Up' : 'Down'}</span>
|
<span style="font-weight:400;color:var(--text-muted);font-size:9px;letter-spacing:0.5px;">${iface.mac || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-row-label">IPv4</div>
|
<div>
|
||||||
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv4 || '--'}</span>
|
<div class="settings-row-label">Status</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="color:${isUp ? 'var(--status-green)' : 'var(--status-red)'};">${iface.state}</span>
|
||||||
|
<button class="settings-btn secondary" style="height:24px;font-size:11px;padding:0 8px;"
|
||||||
|
data-iface-toggle="${iface.name}" data-is-up="${isUp}">
|
||||||
|
${isUp ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-row-label">IPv6</div>
|
<div>
|
||||||
<span style="font-family:var(--font-mono);font-size:13px;">${info.ipv6 || '--'}</span>
|
<div class="settings-row-label">IPv4</div>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:13px;">${ipDisplay}</span>
|
||||||
|
</div>
|
||||||
|
${iface.gateway ? `
|
||||||
|
<div class="settings-row">
|
||||||
|
<div><div class="settings-row-label">Gateway</div></div>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:13px;">${iface.gateway}</span>
|
||||||
|
</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' : ''}" data-iface-dhcp="${iface.name}">
|
||||||
|
<span class="toggle-label">${isStatic ? 'Static' : 'DHCP'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="net-static-fields ${isStatic ? '' : 'hidden'}" id="staticFields-${iface.name}">
|
||||||
|
<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="ifAddr-${iface.name}" value="${cidr}" 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="ifGw-${iface.name}" value="${iface.gateway || iface.config_gateway || ''}" 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="ifDns1-${iface.name}" value="${dnsServers[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="ifDns2-${iface.name}" value="${dnsServers[1] || ''}" placeholder="8.8.4.4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ifError-${iface.name}" class="net-error hidden"></div>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="settings-btn" data-iface-apply="${iface.name}">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNS section
|
||||||
|
html += `
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">DNS</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-row-label">Nameservers</div>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:13px;">${dnsServers.join(', ') || '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
contentEl.innerHTML = html;
|
contentEl.innerHTML = html;
|
||||||
|
|
||||||
|
// ---- Event handlers ----
|
||||||
|
|
||||||
|
// Interface up/down toggle
|
||||||
|
contentEl.querySelectorAll('[data-iface-toggle]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const name = btn.dataset.ifaceToggle;
|
||||||
|
const isUp = btn.dataset.isUp === 'true';
|
||||||
|
const action = isUp ? 'down' : 'up';
|
||||||
|
|
||||||
|
if (isUp && !confirm(`Bring down ${name}? You may lose connectivity if this is your primary interface.`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = isUp ? 'Disabling…' : 'Enabling…';
|
||||||
|
try {
|
||||||
|
await Atlus.apiFetch(`/api/network/interfaces/${name}/${action}`, { method: 'POST' });
|
||||||
|
} catch (e) { /* may lose connection */ }
|
||||||
|
setTimeout(() => _renderNetworkReadonly(), 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DHCP/Static toggle
|
||||||
|
contentEl.querySelectorAll('[data-iface-dhcp]').forEach(toggle => {
|
||||||
|
const name = toggle.dataset.ifaceDhcp;
|
||||||
|
const staticFields = contentEl.querySelector(`#staticFields-${name}`);
|
||||||
|
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 config
|
||||||
|
contentEl.querySelectorAll('[data-iface-apply]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const name = btn.dataset.ifaceApply;
|
||||||
|
const errEl = contentEl.querySelector(`#ifError-${name}`);
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const toggle = contentEl.querySelector(`[data-iface-dhcp="${name}"]`);
|
||||||
|
const isStatic = toggle.classList.contains('on');
|
||||||
|
const method = isStatic ? 'static' : 'dhcp';
|
||||||
|
const body = { method };
|
||||||
|
|
||||||
|
if (isStatic) {
|
||||||
|
body.address = contentEl.querySelector(`#ifAddr-${name}`).value.trim();
|
||||||
|
body.gateway = contentEl.querySelector(`#ifGw-${name}`).value.trim();
|
||||||
|
const dns1 = contentEl.querySelector(`#ifDns1-${name}`).value.trim();
|
||||||
|
const dns2 = contentEl.querySelector(`#ifDns2-${name}`).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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Apply network changes to ${name}? If this is your primary interface, you may lose access temporarily.`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Applying…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch(`/api/network/interfaces/${name}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.ok) {
|
||||||
|
btn.textContent = 'Applied ✓';
|
||||||
|
setTimeout(() => _renderNetworkReadonly(), 3000);
|
||||||
|
} else {
|
||||||
|
const err = res ? await res.json().catch(() => ({})) : {};
|
||||||
|
errEl.textContent = err.detail || 'Failed to apply configuration.';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Apply';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Connection may be lost if changing the interface we're connected through
|
||||||
|
btn.textContent = 'Applied (reconnecting…)';
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _loadEthernetConfig(dev) {
|
async function _loadEthernetConfig(dev) {
|
||||||
|
|
|
||||||
301
frontend/js/apps/xdisplay.js
Normal file
301
frontend/js/apps/xdisplay.js
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
/* Atlus — X11 Display app (noVNC viewer for virtual desktop) */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let container = null;
|
||||||
|
let iframeEl = null;
|
||||||
|
let statusEl = null;
|
||||||
|
let toolbarEl = null;
|
||||||
|
let wsPort = null;
|
||||||
|
let displayStarted = false;
|
||||||
|
|
||||||
|
// ---- Toolbar buttons state ----
|
||||||
|
let appListEl = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
// ---- Known launchable apps ----
|
||||||
|
const KNOWN_APPS = [
|
||||||
|
{ command: 'nextcloud', name: 'Nextcloud', has_tray_icon: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- Display management ----
|
||||||
|
|
||||||
|
async function startDisplay() {
|
||||||
|
setStatus('Starting display…', 'info');
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/display/start', { method: 'POST' });
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const err = res ? await res.json().catch(() => ({ detail: 'Unknown error' })) : { detail: 'No response' };
|
||||||
|
setStatus(err.detail || 'Failed to start display', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
wsPort = data.ws_port;
|
||||||
|
displayStarted = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Failed to start display: ' + e.message, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectViewer() {
|
||||||
|
if (!displayStarted) {
|
||||||
|
const ok = await startDisplay();
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Connecting to display…', 'info');
|
||||||
|
|
||||||
|
// Use noVNC served by websockify
|
||||||
|
// The websockify --web flag serves noVNC files
|
||||||
|
// Try direct websockify noVNC endpoint first
|
||||||
|
const host = location.hostname;
|
||||||
|
const novncUrl = `http://${host}:${wsPort}/vnc.html?host=${host}&port=${wsPort}&autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000&view_only=false`;
|
||||||
|
|
||||||
|
if (iframeEl) {
|
||||||
|
iframeEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
iframeEl = document.createElement('iframe');
|
||||||
|
iframeEl.className = 'xdisplay-iframe';
|
||||||
|
iframeEl.src = novncUrl;
|
||||||
|
iframeEl.setAttribute('allowfullscreen', 'true');
|
||||||
|
|
||||||
|
iframeEl.onload = () => {
|
||||||
|
statusEl.classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
iframeEl.onerror = () => {
|
||||||
|
// If noVNC files aren't served by websockify, fallback to raw WebSocket viewer
|
||||||
|
setStatus('noVNC not available — install novnc package', 'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerArea = container.querySelector('.xdisplay-viewer');
|
||||||
|
viewerArea.appendChild(iframeEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- App launcher ----
|
||||||
|
|
||||||
|
async function launchApp(command, args, name, hasTrayIcon) {
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/display/apps/launch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { command, args: args || [], name: name || command, has_tray_icon: hasTrayIcon || false },
|
||||||
|
});
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const err = res ? await res.json().catch(() => ({})) : {};
|
||||||
|
console.error('Failed to launch app:', err.detail || 'unknown');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
refreshAppList();
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to launch app:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopApp(appId) {
|
||||||
|
try {
|
||||||
|
await Atlus.apiFetch(`/api/display/apps/${appId}`, { method: 'DELETE' });
|
||||||
|
} catch (e) { /* best effort */ }
|
||||||
|
refreshAppList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAppList() {
|
||||||
|
if (!appListEl) return;
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/display/apps');
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
renderAppList(data.apps || []);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAppList(apps) {
|
||||||
|
if (!appListEl) return;
|
||||||
|
appListEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (apps.length === 0) {
|
||||||
|
appListEl.innerHTML = '<span class="xdisplay-no-apps">No apps running</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
apps.forEach(app => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'xdisplay-app-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="xdisplay-app-name">${app.name}</span>
|
||||||
|
<button class="xdisplay-app-stop" data-id="${app.app_id}" title="Stop">×</button>
|
||||||
|
`;
|
||||||
|
item.querySelector('.xdisplay-app-stop').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
stopApp(app.app_id);
|
||||||
|
});
|
||||||
|
appListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status ----
|
||||||
|
|
||||||
|
function setStatus(message, type) {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = 'xdisplay-status';
|
||||||
|
if (type) statusEl.classList.add(type);
|
||||||
|
statusEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- App registration ----
|
||||||
|
|
||||||
|
Atlus.registerApp('xdisplay', {
|
||||||
|
title: 'Display',
|
||||||
|
|
||||||
|
init(el) {
|
||||||
|
container = el;
|
||||||
|
container.classList.add('app-xdisplay');
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
toolbarEl = document.createElement('div');
|
||||||
|
toolbarEl.className = 'xdisplay-toolbar';
|
||||||
|
|
||||||
|
// Launch dropdown
|
||||||
|
const launchGroup = document.createElement('div');
|
||||||
|
launchGroup.className = 'xdisplay-launch-group';
|
||||||
|
|
||||||
|
const launchBtn = document.createElement('button');
|
||||||
|
launchBtn.className = 'xdisplay-btn xdisplay-launch-btn';
|
||||||
|
launchBtn.textContent = '+ Launch App';
|
||||||
|
|
||||||
|
const launchMenu = document.createElement('div');
|
||||||
|
launchMenu.className = 'xdisplay-launch-menu hidden';
|
||||||
|
|
||||||
|
KNOWN_APPS.forEach(app => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'xdisplay-launch-menu-item';
|
||||||
|
item.textContent = app.name;
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
launchMenu.classList.add('hidden');
|
||||||
|
launchApp(app.command, [], app.name, app.has_tray_icon);
|
||||||
|
});
|
||||||
|
launchMenu.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom app option
|
||||||
|
const customItem = document.createElement('button');
|
||||||
|
customItem.className = 'xdisplay-launch-menu-item';
|
||||||
|
customItem.textContent = 'Custom…';
|
||||||
|
customItem.addEventListener('click', () => {
|
||||||
|
launchMenu.classList.add('hidden');
|
||||||
|
const cmd = prompt('Enter command to launch:');
|
||||||
|
if (cmd && cmd.trim()) {
|
||||||
|
const parts = cmd.trim().split(/\s+/);
|
||||||
|
launchApp(parts[0], parts.slice(1), parts[0], false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
launchMenu.appendChild(customItem);
|
||||||
|
|
||||||
|
launchBtn.addEventListener('click', () => {
|
||||||
|
launchMenu.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!launchGroup.contains(e.target)) {
|
||||||
|
launchMenu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
launchGroup.appendChild(launchBtn);
|
||||||
|
launchGroup.appendChild(launchMenu);
|
||||||
|
toolbarEl.appendChild(launchGroup);
|
||||||
|
|
||||||
|
// Running apps list
|
||||||
|
appListEl = document.createElement('div');
|
||||||
|
appListEl.className = 'xdisplay-app-list';
|
||||||
|
toolbarEl.appendChild(appListEl);
|
||||||
|
|
||||||
|
// Connection button (right side)
|
||||||
|
const connectBtn = document.createElement('button');
|
||||||
|
connectBtn.className = 'xdisplay-btn xdisplay-connect-btn';
|
||||||
|
connectBtn.textContent = 'Connect';
|
||||||
|
connectBtn.addEventListener('click', () => connectViewer());
|
||||||
|
toolbarEl.appendChild(connectBtn);
|
||||||
|
|
||||||
|
container.appendChild(toolbarEl);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
statusEl = document.createElement('div');
|
||||||
|
statusEl.className = 'xdisplay-status';
|
||||||
|
statusEl.textContent = 'Click Connect to start the virtual display';
|
||||||
|
|
||||||
|
// Viewer area
|
||||||
|
const viewerArea = document.createElement('div');
|
||||||
|
viewerArea.className = 'xdisplay-viewer';
|
||||||
|
viewerArea.appendChild(statusEl);
|
||||||
|
container.appendChild(viewerArea);
|
||||||
|
|
||||||
|
// Check if display is already running
|
||||||
|
checkExistingDisplay();
|
||||||
|
|
||||||
|
// Poll running apps every 5 seconds
|
||||||
|
refreshTimer = setInterval(refreshAppList, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
if (iframeEl) {
|
||||||
|
iframeEl.remove();
|
||||||
|
iframeEl = null;
|
||||||
|
}
|
||||||
|
container = null;
|
||||||
|
toolbarEl = null;
|
||||||
|
statusEl = null;
|
||||||
|
appListEl = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onFocus() {
|
||||||
|
refreshAppList();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Public API: launch an app from outside (e.g., panel tray click) */
|
||||||
|
launchApp(command, args, name, hasTrayIcon) {
|
||||||
|
return launchApp(command, args, name, hasTrayIcon);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Public API: connect/show the viewer */
|
||||||
|
showViewer() {
|
||||||
|
if (!displayStarted) {
|
||||||
|
connectViewer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkExistingDisplay() {
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/display/status');
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.missing && data.missing.length > 0) {
|
||||||
|
setStatus(
|
||||||
|
`Missing packages: ${data.missing.join(', ')}. Install with: sudo apt install ${data.missing.map(n => n.toLowerCase()).join(' ')}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session && data.session.started) {
|
||||||
|
wsPort = data.session.ws_port;
|
||||||
|
displayStarted = true;
|
||||||
|
connectViewer();
|
||||||
|
refreshAppList();
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -397,17 +397,36 @@
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
try {
|
try {
|
||||||
const res = await Atlus.apiFetch('/api/updates/check');
|
const res = await Atlus.apiFetch('/api/updates/check');
|
||||||
if (!res || !res.ok) return;
|
if (!res || !res.ok) {
|
||||||
|
// Show error state if endpoint fails (503 = git not found, etc.)
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
const status = res ? res.status : 0;
|
||||||
|
const detail = res ? await res.json().catch(() => ({})) : {};
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="update-status">
|
||||||
|
<span class="update-status-dot" style="background:var(--text-muted);"></span>
|
||||||
|
<div class="update-status-text">
|
||||||
|
<div class="update-status-title">Updates unavailable</div>
|
||||||
|
<div class="update-status-hash">${detail.detail || 'Could not check'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
panel.classList.remove('hidden');
|
panel.classList.remove('hidden');
|
||||||
|
|
||||||
if (data.available && data.behind_count > 0) {
|
if (data.error) {
|
||||||
|
showUpdateError(panel, data);
|
||||||
|
} else if (data.available && data.behind_count > 0) {
|
||||||
showUpdateAvailable(panel, data);
|
showUpdateAvailable(panel, data);
|
||||||
} else {
|
} else {
|
||||||
showUpToDate(panel, data);
|
showUpToDate(panel, data);
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) {
|
||||||
|
console.warn('Update check failed:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUpToDate(panel, data) {
|
function showUpToDate(panel, data) {
|
||||||
|
|
@ -422,6 +441,18 @@
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showUpdateError(panel, data) {
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="update-status">
|
||||||
|
<span class="update-status-dot" style="background:var(--status-red);"></span>
|
||||||
|
<div class="update-status-text">
|
||||||
|
<div class="update-status-title">Check failed</div>
|
||||||
|
<div class="update-status-hash">${data.error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function showUpdateAvailable(panel, data) {
|
function showUpdateAvailable(panel, data) {
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="update-status update-available">
|
<div class="update-status update-available">
|
||||||
|
|
@ -463,6 +494,55 @@
|
||||||
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
|
.catch(() => setTimeout(() => attemptReload(attempt + 1), 3000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Panel — Desktop Apps (X11 tray)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
async function pollDesktopApps() {
|
||||||
|
const trayPanel = $('#panelTray');
|
||||||
|
const trayList = $('#panelTrayApps');
|
||||||
|
if (!trayPanel || !trayList) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await Atlus.apiFetch('/api/display/apps');
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
trayPanel.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const apps = data.apps || [];
|
||||||
|
|
||||||
|
if (apps.length === 0) {
|
||||||
|
trayPanel.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trayPanel.classList.remove('hidden');
|
||||||
|
trayList.innerHTML = '';
|
||||||
|
|
||||||
|
apps.forEach(app => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'panel-tray-item';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="panel-tray-dot ${app.alive ? 'running' : 'stopped'}"></span>
|
||||||
|
<span class="panel-tray-name">${app.name}</span>
|
||||||
|
<button class="panel-tray-open" title="Open Display">↗</button>
|
||||||
|
`;
|
||||||
|
// Click app name or open button → switch to display app
|
||||||
|
row.querySelector('.panel-tray-open').addEventListener('click', () => {
|
||||||
|
openApp('xdisplay');
|
||||||
|
});
|
||||||
|
row.querySelector('.panel-tray-name').addEventListener('click', () => {
|
||||||
|
openApp('xdisplay');
|
||||||
|
});
|
||||||
|
trayList.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Display API not available (no X11 deps) — hide tray
|
||||||
|
trayPanel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Session persistence — save/restore desktop state
|
// Session persistence — save/restore desktop state
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
@ -550,6 +630,7 @@
|
||||||
loadPanelServices();
|
loadPanelServices();
|
||||||
connectStats();
|
connectStats();
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
pollDesktopApps();
|
||||||
|
|
||||||
// Refresh services panel periodically
|
// Refresh services panel periodically
|
||||||
setInterval(loadPanelServices, 30000);
|
setInterval(loadPanelServices, 30000);
|
||||||
|
|
@ -557,6 +638,9 @@
|
||||||
// Check for updates every 60 seconds
|
// Check for updates every 60 seconds
|
||||||
setInterval(checkForUpdates, 60 * 1000);
|
setInterval(checkForUpdates, 60 * 1000);
|
||||||
|
|
||||||
|
// Poll desktop apps every 5 seconds
|
||||||
|
setInterval(pollDesktopApps, 5000);
|
||||||
|
|
||||||
// Expose for app modules
|
// Expose for app modules
|
||||||
window.Atlus.openApp = openApp;
|
window.Atlus.openApp = openApp;
|
||||||
window.Atlus.closeApp = closeApp;
|
window.Atlus.closeApp = closeApp;
|
||||||
|
|
|
||||||
17
install.sh
17
install.sh
|
|
@ -52,6 +52,22 @@ install_deps() {
|
||||||
ok "System dependencies installed."
|
ok "System dependencies installed."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_x11_deps() {
|
||||||
|
info "Installing X11 display dependencies (for GUI app support)..."
|
||||||
|
apt-get install -y -qq \
|
||||||
|
xvfb \
|
||||||
|
openbox \
|
||||||
|
x11vnc \
|
||||||
|
novnc \
|
||||||
|
websockify \
|
||||||
|
stalonetray \
|
||||||
|
wmctrl \
|
||||||
|
xdotool \
|
||||||
|
x11-utils \
|
||||||
|
> /dev/null 2>&1
|
||||||
|
ok "X11 display dependencies installed."
|
||||||
|
}
|
||||||
|
|
||||||
install_atlus() {
|
install_atlus() {
|
||||||
info "Installing Atlus to $INSTALL_DIR..."
|
info "Installing Atlus to $INSTALL_DIR..."
|
||||||
|
|
||||||
|
|
@ -126,6 +142,7 @@ main() {
|
||||||
require_root
|
require_root
|
||||||
detect_os
|
detect_os
|
||||||
install_deps
|
install_deps
|
||||||
|
install_x11_deps
|
||||||
install_atlus
|
install_atlus
|
||||||
setup_dirs
|
setup_dirs
|
||||||
install_service
|
install_service
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue