Replace GTK4 desktop app with FastAPI web app (Sovran_SystemsOS Hub)

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-02 17:01:42 +00:00
committed by GitHub
parent af1ad09e2e
commit 42900608f6
17 changed files with 1368 additions and 1183 deletions

View File

@@ -1,40 +0,0 @@
"""Thin wrapper around the systemctl CLI for Sovran_SystemsOS_Hub."""
from __future__ import annotations
import subprocess
from typing import Literal
def _run(cmd: list[str]) -> str:
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return result.stdout.strip()
except Exception:
return ""
def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
def run_action(
action: str,
unit: str,
scope: Literal["system", "user"] = "system",
method: str = "systemctl",
) -> bool:
base_cmd = ["systemctl", f"--{scope}", action, unit]
if scope == "system" and method == "pkexec":
cmd = ["pkexec", "--user", "root"] + base_cmd
else:
cmd = base_cmd
try:
subprocess.Popen(cmd)
return True
except Exception:
return False

View File

@@ -1,727 +0,0 @@
"""Sovran_SystemsOS_Hub — Main GTK4 Application."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import threading
import urllib.request
from datetime import datetime
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk
from .config import load_config
from .service_tile import ServiceTile
APP_ID = "com.sovransystems.hub"
Adw.init()
CATEGORY_ORDER = [
("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"),
("bitcoin-apps", "Bitcoin Apps"),
("communication", "Communication"),
("apps", "Self-Hosted Apps"),
("nostr", "Nostr"),
]
ROLE_LABELS = {
"server_plus_desktop": "Server + Desktop",
"desktop": "Desktop Only",
"node": "Bitcoin Node",
}
AUTOSTART_DIR = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"autostart",
)
USER_AUTOSTART_FILE = os.path.join(AUTOSTART_DIR, "sovran-hub.desktop")
SYSTEM_AUTOSTART_FILE = "/etc/xdg/autostart/sovran-hub.desktop"
DOWNLOADS_DIR = os.path.join(os.path.expanduser("~"), "Downloads")
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
FLAKE_INPUT_NAME = "Sovran_Systems"
GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits"
UPDATE_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y",
]
REBOOT_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"reboot",
]
UPDATE_CHECK_INTERVAL = 1800
TILE_GRID_WIDTH = 820
# ── Autostart helpers ────────────────────────────────────────────
def get_autostart_enabled() -> bool:
if os.path.isfile(USER_AUTOSTART_FILE):
try:
with open(USER_AUTOSTART_FILE, "r") as f:
for line in f:
if line.strip().lower() == "x-gnome-autostart-enabled=false":
return False
if line.strip().lower() == "hidden=true":
return False
return True
except Exception:
return True
return os.path.isfile(SYSTEM_AUTOSTART_FILE)
def set_autostart_enabled(enabled: bool):
os.makedirs(AUTOSTART_DIR, exist_ok=True)
if enabled:
if os.path.isfile(USER_AUTOSTART_FILE):
os.remove(USER_AUTOSTART_FILE)
else:
with open(USER_AUTOSTART_FILE, "w") as f:
f.write("[Desktop Entry]\n")
f.write("Type=Application\n")
f.write("Name=Sovran_SystemsOS Hub\n")
f.write("Exec=sovran-hub\n")
f.write("X-GNOME-Autostart-enabled=false\n")
f.write("Hidden=true\n")
# ── Update check helpers ────────────────────────────────────────
def _get_locked_info():
try:
with open(FLAKE_LOCK_PATH, "r") as f:
lock = json.load(f)
nodes = lock.get("nodes", {})
node = nodes.get(FLAKE_INPUT_NAME, {})
locked = node.get("locked", {})
rev = locked.get("rev")
branch = locked.get("ref")
if not branch:
branch = node.get("original", {}).get("ref")
return rev, branch
except Exception:
pass
return None, None
def _get_remote_rev(branch=None):
try:
url = GITEA_API_BASE + "?limit=1"
if branch:
url += f"&sha={branch}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if isinstance(data, list) and len(data) > 0:
return data[0].get("sha")
except Exception:
pass
return None
def check_for_updates() -> bool:
locked_rev, branch = _get_locked_info()
remote_rev = _get_remote_rev(branch)
if locked_rev and remote_rev:
return locked_rev != remote_rev
return False
# ── IP address helpers ───────────────────────────────────────────
def _get_internal_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.connect(("1.1.1.1", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
pass
try:
result = subprocess.run(
["hostname", "-I"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
parts = result.stdout.strip().split()
if parts:
return parts[0]
except Exception:
pass
return "unavailable"
def _get_external_ip():
for url in [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]:
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=8) as resp:
ip = resp.read().decode().strip()
if ip and len(ip) < 46:
return ip
except Exception:
continue
return "unavailable"
# ── UpdateDialog ─────────────────────────────────────────────────
class UpdateDialog(Adw.Window):
def __init__(self, parent):
super().__init__(
title="Sovran_SystemsOS Update",
default_width=900,
default_height=700,
modal=True,
transient_for=parent,
)
self._process = None
self._full_log = ""
header = Adw.HeaderBar()
self._close_btn = Gtk.Button(label="Close", sensitive=False)
self._close_btn.connect("clicked", lambda _b: self.close())
header.pack_end(self._close_btn)
self._reboot_btn = Gtk.Button(
label="Reboot",
css_classes=["destructive-action"],
tooltip_text="Reboot the system now",
visible=False,
)
self._reboot_btn.connect("clicked", self._on_reboot_clicked)
header.pack_end(self._reboot_btn)
self._save_btn = Gtk.Button(
label="Save Error Report",
css_classes=["warning"],
tooltip_text="Save full log to ~/Downloads",
visible=False,
)
self._save_btn.connect("clicked", self._on_save_report)
header.pack_start(self._save_btn)
self._spinner = Gtk.Spinner(spinning=True)
header.pack_start(self._spinner)
self._status_label = Gtk.Label(
label="Updating…",
css_classes=["title-4"],
halign=Gtk.Align.CENTER,
margin_top=12,
margin_bottom=8,
)
self._textview = Gtk.TextView(
editable=False,
cursor_visible=False,
monospace=True,
wrap_mode=Gtk.WrapMode.WORD_CHAR,
top_margin=8,
bottom_margin=8,
left_margin=12,
right_margin=12,
)
self._buffer = self._textview.get_buffer()
scrolled = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
vexpand=True,
child=self._textview,
)
content = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=0,
)
content.append(self._status_label)
content.append(scrolled)
toolbar_view = Adw.ToolbarView()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(content)
self.set_content(toolbar_view)
self._start_update()
def _append_text(self, text):
self._full_log += text
end_iter = self._buffer.get_end_iter()
self._buffer.insert(end_iter, text)
mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False)
self._textview.scroll_mark_onscreen(mark)
self._buffer.delete_mark(mark)
def _start_update(self):
self._append_text(
"$ ssh root@localhost 'cd /etc/nixos && nix flake update "
"&& nixos-rebuild switch && flatpak update -y'\n\n"
)
thread = threading.Thread(target=self._run_update, daemon=True)
thread.start()
def _run_update(self):
try:
self._process = subprocess.Popen(
UPDATE_COMMAND,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
for line in self._process.stdout:
GLib.idle_add(self._append_text, line)
self._process.wait()
rc = self._process.returncode
if rc == 0:
GLib.idle_add(self._on_finished, True, "Update complete!")
else:
GLib.idle_add(self._on_finished, False, f"Update failed (exit code {rc})")
except Exception as e:
GLib.idle_add(self._on_finished, False, f"Error: {e}")
def _on_finished(self, success, message):
self._spinner.set_spinning(False)
self._close_btn.set_sensitive(True)
if success:
self._status_label.set_label("" + message)
self._status_label.set_css_classes(["title-4", "success"])
self._reboot_btn.set_visible(True)
else:
self._status_label.set_label("" + message)
self._status_label.set_css_classes(["title-4", "error"])
self._save_btn.set_visible(True)
self._append_text(f"\n{'' * 60}\n{message}\n")
def _on_save_report(self, _btn):
os.makedirs(DOWNLOADS_DIR, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"sovran-update-error-{timestamp}.log"
filepath = os.path.join(DOWNLOADS_DIR, filename)
try:
with open(filepath, "w") as f:
f.write(f"Sovran_SystemsOS Update Error Report\n")
f.write(f"Date: {datetime.now().isoformat()}\n")
f.write(f"{'' * 60}\n\n")
f.write(self._full_log)
self._save_btn.set_label(f"Saved: {filename}")
self._save_btn.set_sensitive(False)
self._append_text(f"\n✓ Error report saved to ~/Downloads/{filename}\n")
except Exception as e:
self._append_text(f"\n✗ Failed to save report: {e}\n")
def _on_reboot_clicked(self, _btn):
dialog = Adw.MessageDialog(
transient_for=self,
heading="Reboot Now?",
body="The system will restart immediately. Save any open work first.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("reboot", "Reboot")
dialog.set_response_appearance("reboot", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
dialog.connect("response", self._on_reboot_confirmed)
dialog.present()
def _on_reboot_confirmed(self, dialog, response):
if response == "reboot":
try:
subprocess.Popen(REBOOT_COMMAND)
except Exception as e:
self._append_text(f"\n✗ Reboot failed: {e}\n")
# ── Main Window ──────────────────────────────────────────────────
class SovranHubWindow(Adw.ApplicationWindow):
def __init__(self, app, config):
super().__init__(
application=app,
title="Sovran_SystemsOS Hub",
default_width=860,
default_height=800,
)
self._config = config
self._tiles = []
self._update_available = False
css_path = os.environ.get("SOVRAN_HUB_CSS", "")
if css_path and os.path.isfile(css_path):
provider = Gtk.CssProvider()
provider.load_from_path(css_path)
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
header = Adw.HeaderBar()
header.set_title_widget(self._build_title_box())
self._update_btn = Gtk.Button(
label="Update System",
css_classes=["suggested-action"],
tooltip_text="System is up to date",
)
self._update_btn.connect("clicked", self._on_update_clicked)
header.pack_start(self._update_btn)
self._badge = Gtk.Label(
label="",
css_classes=["update-badge"],
visible=False,
)
header.pack_start(self._badge)
refresh_btn = Gtk.Button(
icon_name="view-refresh-symbolic",
tooltip_text="Refresh now",
)
refresh_btn.connect("clicked", lambda _b: self._refresh_all())
header.pack_end(refresh_btn)
menu_btn = Gtk.MenuButton(
icon_name="open-menu-symbolic",
tooltip_text="Settings",
)
popover = Gtk.Popover()
menu_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=8,
margin_top=12,
margin_bottom=12,
margin_start=12,
margin_end=12,
)
autostart_row = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=12,
)
autostart_label = Gtk.Label(
label="Start at login",
hexpand=True,
halign=Gtk.Align.START,
)
self._autostart_switch = Gtk.Switch(
valign=Gtk.Align.CENTER,
active=get_autostart_enabled(),
)
self._autostart_switch.connect("state-set", self._on_autostart_toggled)
autostart_row.append(autostart_label)
autostart_row.append(self._autostart_switch)
menu_box.append(autostart_row)
popover.set_child(menu_box)
menu_btn.set_popover(popover)
header.pack_end(menu_btn)
# ── Main content area ────────────────────────────────────
self._main_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=0,
)
# ── IP Address Banner ────────────────────────────────────
self._ip_bar = self._build_ip_bar()
self._main_box.append(self._ip_bar)
scrolled = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
vexpand=True,
)
self._tiles_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=0,
)
scrolled.set_child(self._tiles_box)
self._main_box.append(scrolled)
toolbar_view = Adw.ToolbarView()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self._main_box)
self.set_content(toolbar_view)
self._build_tiles()
interval = config.get("refresh_interval", 5)
if interval and interval > 0:
GLib.timeout_add_seconds(interval, self._auto_refresh)
GLib.timeout_add_seconds(5, self._check_for_updates_once)
GLib.timeout_add_seconds(UPDATE_CHECK_INTERVAL, self._periodic_update_check)
GLib.timeout_add_seconds(1, self._fetch_ips_once)
# ── IP Address Bar ───────────────────────────────────────────
def _build_ip_bar(self):
bar = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=28,
halign=Gtk.Align.CENTER,
margin_top=14,
margin_bottom=6,
margin_start=24,
margin_end=24,
css_classes=["ip-bar"],
)
internal_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=8,
)
internal_icon = Gtk.Image(
icon_name="network-wired-symbolic",
pixel_size=18,
css_classes=["dim-label"],
)
internal_label = Gtk.Label(
label="Internal:",
css_classes=["dim-label"],
)
self._internal_ip_label = Gtk.Label(
label="",
css_classes=["ip-value"],
selectable=True,
)
internal_box.append(internal_icon)
internal_box.append(internal_label)
internal_box.append(self._internal_ip_label)
sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
external_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=8,
)
external_icon = Gtk.Image(
icon_name="network-server-symbolic",
pixel_size=18,
css_classes=["dim-label"],
)
external_label = Gtk.Label(
label="External:",
css_classes=["dim-label"],
)
self._external_ip_label = Gtk.Label(
label="",
css_classes=["ip-value"],
selectable=True,
)
external_box.append(external_icon)
external_box.append(external_label)
external_box.append(self._external_ip_label)
bar.append(internal_box)
bar.append(sep)
bar.append(external_box)
return bar
def _fetch_ips_once(self):
thread = threading.Thread(target=self._do_fetch_ips, daemon=True)
thread.start()
return False
def _do_fetch_ips(self):
internal = _get_internal_ip()
GLib.idle_add(self._internal_ip_label.set_label, internal)
external = _get_external_ip()
GLib.idle_add(self._external_ip_label.set_label, external)
# ── Title box ────────────────────────────────────────────────
def _build_title_box(self):
role = self._config.get("role", "server_plus_desktop")
role_label = ROLE_LABELS.get(role, role)
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
halign=Gtk.Align.CENTER,
)
box.append(Gtk.Label(
label="Sovran_SystemsOS Hub",
css_classes=["hub-title"],
))
box.append(Gtk.Label(
label=role_label,
css_classes=["role-badge", "dim-label"],
))
return box
# ── Service tiles ────────────────────────────────────────────
def _build_tiles(self):
method = self._config.get("command_method", "systemctl")
services = self._config.get("services", [])
grouped = {}
for entry in services:
cat = entry.get("category", "other")
grouped.setdefault(cat, []).append(entry)
for cat_key, cat_label in CATEGORY_ORDER:
entries = grouped.get(cat_key, [])
if not entries:
continue
container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
halign=Gtk.Align.CENTER,
css_classes=["tiles-container"],
)
container.set_size_request(TILE_GRID_WIDTH, -1)
section_label = Gtk.Label(
label=cat_label,
css_classes=["section-header"],
halign=Gtk.Align.START,
margin_top=24,
margin_bottom=6,
margin_start=16,
)
container.append(section_label)
sep = Gtk.Separator(
orientation=Gtk.Orientation.HORIZONTAL,
margin_start=16,
margin_end=16,
margin_bottom=10,
)
container.append(sep)
flowbox = Gtk.FlowBox(
max_children_per_line=4,
min_children_per_line=2,
selection_mode=Gtk.SelectionMode.NONE,
homogeneous=False,
row_spacing=14,
column_spacing=14,
margin_top=4,
margin_bottom=10,
margin_start=16,
margin_end=16,
halign=Gtk.Align.START,
valign=Gtk.Align.START,
)
for entry in entries:
tile = ServiceTile(
name=entry.get("name", entry["unit"]),
unit=entry["unit"],
scope=entry.get("type", "system"),
method=method,
icon_name=entry.get("icon", ""),
enabled=entry.get("enabled", True),
)
flowbox.append(tile)
self._tiles.append(tile)
container.append(flowbox)
self._tiles_box.append(container)
GLib.idle_add(self._refresh_all)
# ── Update check ─────────────────────────────────────────────
def _check_for_updates_once(self):
thread = threading.Thread(target=self._do_update_check, daemon=True)
thread.start()
return False
def _periodic_update_check(self):
thread = threading.Thread(target=self._do_update_check, daemon=True)
thread.start()
return True
def _do_update_check(self):
available = check_for_updates()
GLib.idle_add(self._set_update_indicator, available)
def _set_update_indicator(self, available):
self._update_available = available
if available:
self._update_btn.set_label("Update Available")
self._update_btn.set_css_classes(["update-available"])
self._update_btn.set_tooltip_text("A new version of Sovran_SystemsOS is available!")
self._badge.set_visible(True)
else:
self._update_btn.set_label("Update System")
self._update_btn.set_css_classes(["suggested-action"])
self._update_btn.set_tooltip_text("System is up to date")
self._badge.set_visible(False)
# ── Callbacks ────────────────────────────────────────────────
def _on_update_clicked(self, _btn):
dialog = UpdateDialog(self)
dialog.connect("close-request", lambda _w: self._after_update())
dialog.present()
def _after_update(self):
GLib.timeout_add_seconds(3, self._check_for_updates_once)
return False
def _on_autostart_toggled(self, switch, state):
set_autostart_enabled(state)
return False
def _refresh_all(self):
for t in self._tiles:
t.refresh()
return False
def _auto_refresh(self):
self._refresh_all()
return True
class SovranHubApp(Adw.Application):
def __init__(self):
super().__init__(
application_id=APP_ID,
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
)
self._config = load_config()
def do_activate(self):
win = self.get_active_window()
if not win:
win = SovranHubWindow(self, self._config)
win.present()

View File

@@ -1,104 +0,0 @@
"""Adw.ActionRow subclass representing a single systemd unit."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk # noqa: E402
from . import systemctl # noqa: E402
LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"}
class ServiceRow(Adw.ActionRow):
"""A row showing one systemd unit with a toggle switch and action buttons."""
def __init__(
self,
name: str,
unit: str,
scope: str = "system",
method: str = "systemctl",
**kwargs,
):
super().__init__(title=name, subtitle=unit, **kwargs)
self._unit = unit
self._scope = scope
self._method = method
# ── Active / Inactive switch ──
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
self._switch.connect("state-set", self._on_toggled)
self.add_suffix(self._switch)
self.set_activatable_widget(self._switch)
# ── Restart button ──
restart_btn = Gtk.Button(
icon_name="view-refresh-symbolic",
valign=Gtk.Align.CENTER,
tooltip_text="Restart",
css_classes=["flat"],
)
restart_btn.connect("clicked", self._on_restart)
self.add_suffix(restart_btn)
# ── Status pill ──
self._status_label = Gtk.Label(
css_classes=["caption", "dim-label"],
valign=Gtk.Align.CENTER,
margin_end=4,
)
self.add_suffix(self._status_label)
# Initial state
self.refresh()
# ── public API ──
def refresh(self):
"""Poll systemctl and update the row."""
active_state = systemctl.is_active(self._unit, self._scope)
enabled_state = systemctl.is_enabled(self._unit, self._scope)
is_active = active_state == "active"
is_loading = active_state in LOADING_STATES
is_failed = active_state == "failed"
# Block the handler so we don't trigger a start/stop when we
# programmatically flip the switch.
self._switch.handler_block_by_func(self._on_toggled)
self._switch.set_active(is_active)
self._switch.handler_unblock_by_func(self._on_toggled)
self._switch.set_sensitive(not is_loading)
# Status text
label = enabled_state
if is_failed:
label = "failed"
elif is_loading:
label = active_state
self._status_label.set_label(label)
# Visual cue for failures
if is_failed:
self.add_css_class("error")
else:
self.remove_css_class("error")
# ── signal handlers ──
def _on_toggled(self, switch: Gtk.Switch, state: bool) -> bool:
action = "start" if state else "stop"
systemctl.run_action(action, self._unit, self._scope, self._method)
# Delay refresh so systemd has a moment to change state
GLib.timeout_add(1500, self.refresh)
return False # let GTK update the switch position
def _on_restart(self, _btn: Gtk.Button):
systemctl.run_action("restart", self._unit, self._scope, self._method)
GLib.timeout_add(1500, self.refresh)

View File

@@ -1,172 +0,0 @@
"""Square tile widget representing a single systemd unit."""
from __future__ import annotations
import os
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, Pango
from . import systemctl
LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"}
ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "")
ICON_EXTENSIONS = [".svg", ".png"]
# ── Locked tile dimensions ───────────────────────────────────────
TILE_W = 180
TILE_H = 210
ICON_PX = 48
LABEL_W = TILE_W - 24 # 12px padding each side
class ServiceTile(Gtk.Box):
def __init__(self, name, unit, scope="system", method="systemctl",
icon_name="", enabled=True, **kw):
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
spacing=2,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
css_classes=["card", "sovran-tile"],
**kw,
)
self.set_size_request(TILE_W, TILE_H)
self.set_hexpand(False)
self.set_vexpand(False)
self._unit = unit
self._scope = scope
self._method = method
self._enabled = enabled
# ── Icon ─────────────────────────────────────────────────
self._logo = Gtk.Image(
pixel_size=ICON_PX,
margin_top=18,
halign=Gtk.Align.CENTER,
)
self._set_logo(icon_name)
self.append(self._logo)
# ── Name label ───────────────────────────────────────────
self._name_label = Gtk.Label(
label=name,
css_classes=["tile-name"],
halign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
wrap=True,
wrap_mode=Pango.WrapMode.WORD_CHAR,
lines=2,
ellipsize=Pango.EllipsizeMode.END,
margin_start=12,
margin_end=12,
margin_top=6,
)
self._name_label.set_size_request(LABEL_W, -1)
self._name_label.set_max_width_chars(1)
self.append(self._name_label)
# ── Status label ─────────────────────────────────────────
self._status_label = Gtk.Label(
label="● …",
css_classes=["caption", "tile-status", "dim-label"],
halign=Gtk.Align.CENTER,
margin_top=2,
)
self.append(self._status_label)
# ── Spacer ───────────────────────────────────────────────
spacer = Gtk.Box(vexpand=True)
self.append(spacer)
# ── Controls ───────────────<E29480><E29480><EFBFBD>─────────────────────────────
controls = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=10,
halign=Gtk.Align.CENTER,
margin_bottom=14,
)
self._switch = Gtk.Switch(valign=Gtk.Align.CENTER)
self._switch.connect("state-set", self._on_toggled)
controls.append(self._switch)
restart_btn = Gtk.Button(
icon_name="view-refresh-symbolic",
valign=Gtk.Align.CENTER,
tooltip_text="Restart",
css_classes=["flat", "circular"],
)
restart_btn.connect("clicked", self._on_restart)
controls.append(restart_btn)
self.append(controls)
if not self._enabled:
self._switch.set_active(False)
self._switch.set_sensitive(False)
self._status_label.set_label("○ disabled")
self._status_label.set_css_classes(["caption", "tile-status", "disabled-label"])
self._logo.set_opacity(0.35)
self.set_tooltip_text(f"{name} is not enabled in custom.nix")
def _set_logo(self, icon_name):
if icon_name and ICON_DIR:
for ext in ICON_EXTENSIONS:
path = os.path.join(ICON_DIR, f"{icon_name}{ext}")
if os.path.isfile(path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
path, ICON_PX, ICON_PX, True)
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
self._logo.set_from_paintable(texture)
return
except Exception:
break
self._logo.set_from_icon_name("system-run-symbolic")
def refresh(self):
if not self._enabled:
return
active = systemctl.is_active(self._unit, self._scope)
is_on = active == "active"
is_loading = active in LOADING_STATES
is_failed = active == "failed"
self._switch.handler_block_by_func(self._on_toggled)
self._switch.set_active(is_on)
self._switch.handler_unblock_by_func(self._on_toggled)
self._switch.set_sensitive(not is_loading)
if is_failed:
self._status_label.set_label("● failed")
self._status_label.set_css_classes(["caption", "tile-status", "error"])
elif is_on:
self._status_label.set_label("● running")
self._status_label.set_css_classes(["caption", "tile-status", "success"])
elif is_loading:
self._status_label.set_label(f"{active}")
self._status_label.set_css_classes(["caption", "tile-status", "warning"])
else:
self._status_label.set_label(f"{active}")
self._status_label.set_css_classes(["caption", "tile-status", "dim-label"])
def _on_toggled(self, switch, state):
if not self._enabled:
return True
systemctl.run_action("start" if state else "stop", self._unit, self._scope, self._method)
GLib.timeout_add(1500, self.refresh)
return False
def _on_restart(self, _btn):
if not self._enabled:
return
systemctl.run_action("restart", self._unit, self._scope, self._method)
GLib.timeout_add(1500, self.refresh)

View File

View File

@@ -0,0 +1,329 @@
"""Sovran_SystemsOS Hub — FastAPI web server."""
from __future__ import annotations
import asyncio
import json
import os
import socket
import subprocess
import threading
import urllib.request
from typing import AsyncIterator
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.requests import Request
from .config import load_config
from . import systemctl as sysctl
# ── Constants ────────────────────────────────────────────────────
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
FLAKE_INPUT_NAME = "Sovran_Systems"
GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovran_SystemsOS/commits"
REBOOT_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"reboot",
]
UPDATE_COMMAND = [
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
"root@localhost",
"cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y",
]
CATEGORY_ORDER = [
("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"),
("bitcoin-apps", "Bitcoin Apps"),
("communication", "Communication"),
("apps", "Self-Hosted Apps"),
("nostr", "Nostr"),
]
ROLE_LABELS = {
"server_plus_desktop": "Server + Desktop",
"desktop": "Desktop Only",
"node": "Bitcoin Node",
}
# ── App setup ────────────────────────────────────────────────────
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = FastAPI(title="Sovran_SystemsOS Hub")
app.mount(
"/static",
StaticFiles(directory=os.path.join(_BASE_DIR, "static")),
name="static",
)
# Also serve icons from the app/icons directory (set via env or adjacent folder)
_ICONS_DIR = os.environ.get(
"SOVRAN_HUB_ICONS",
os.path.join(os.path.dirname(_BASE_DIR), "icons"),
)
if os.path.isdir(_ICONS_DIR):
app.mount(
"/static/icons",
StaticFiles(directory=_ICONS_DIR),
name="icons",
)
templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates"))
# ── Update check helpers ─────────────────────────────────────────
def _get_locked_info():
try:
with open(FLAKE_LOCK_PATH, "r") as f:
lock = json.load(f)
nodes = lock.get("nodes", {})
node = nodes.get(FLAKE_INPUT_NAME, {})
locked = node.get("locked", {})
rev = locked.get("rev")
branch = locked.get("ref")
if not branch:
branch = node.get("original", {}).get("ref")
return rev, branch
except Exception:
pass
return None, None
def _get_remote_rev(branch=None):
try:
url = GITEA_API_BASE + "?limit=1"
if branch:
url += f"&sha={branch}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if isinstance(data, list) and len(data) > 0:
return data[0].get("sha")
except Exception:
pass
return None
def check_for_updates() -> bool:
locked_rev, branch = _get_locked_info()
remote_rev = _get_remote_rev(branch)
if locked_rev and remote_rev:
return locked_rev != remote_rev
return False
# ── IP helpers ───────────────────────────────────────────────────
def _get_internal_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.connect(("1.1.1.1", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
pass
try:
result = subprocess.run(
["hostname", "-I"], capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
parts = result.stdout.strip().split()
if parts:
return parts[0]
except Exception:
pass
return "unavailable"
def _get_external_ip() -> str:
# Max length 46 covers the longest valid IPv6 address (45 chars) plus a newline
MAX_IP_LENGTH = 46
for url in [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]:
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=8) as resp:
ip = resp.read().decode().strip()
if ip and len(ip) < MAX_IP_LENGTH:
return ip
except Exception:
continue
return "unavailable"
# ── Routes ───────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/config")
async def api_config():
cfg = load_config()
role = cfg.get("role", "server_plus_desktop")
return {
"role": role,
"role_label": ROLE_LABELS.get(role, role),
"category_order": CATEGORY_ORDER,
}
@app.get("/api/services")
async def api_services():
cfg = load_config()
method = cfg.get("command_method", "systemctl")
services = cfg.get("services", [])
loop = asyncio.get_event_loop()
async def get_status(entry):
unit = entry.get("unit", "")
scope = entry.get("type", "system")
enabled = entry.get("enabled", True)
if enabled:
status = await loop.run_in_executor(
None, lambda: sysctl.is_active(unit, scope)
)
else:
status = "disabled"
return {
"name": entry.get("name", ""),
"unit": unit,
"type": scope,
"icon": entry.get("icon", ""),
"enabled": enabled,
"category": entry.get("category", "other"),
"status": status,
}
results = await asyncio.gather(*[get_status(s) for s in services])
return list(results)
def _get_allowed_units() -> set[str]:
"""Return the set of unit names from the current config (whitelist)."""
cfg = load_config()
return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")}
@app.post("/api/services/{unit}/start")
async def service_start(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("start", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to start {unit}")
return {"ok": True}
@app.post("/api/services/{unit}/stop")
async def service_stop(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("stop", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to stop {unit}")
return {"ok": True}
@app.post("/api/services/{unit}/restart")
async def service_restart(unit: str):
if unit not in _get_allowed_units():
raise HTTPException(status_code=403, detail=f"Unit {unit!r} is not in the allowed service list")
cfg = load_config()
method = cfg.get("command_method", "systemctl")
loop = asyncio.get_event_loop()
ok = await loop.run_in_executor(
None, lambda: sysctl.run_action("restart", unit, "system", method)
)
if not ok:
raise HTTPException(status_code=500, detail=f"Failed to restart {unit}")
return {"ok": True}
@app.get("/api/network")
async def api_network():
loop = asyncio.get_event_loop()
internal, external = await asyncio.gather(
loop.run_in_executor(None, _get_internal_ip),
loop.run_in_executor(None, _get_external_ip),
)
return {"internal_ip": internal, "external_ip": external}
@app.get("/api/updates/check")
async def api_updates_check():
loop = asyncio.get_event_loop()
available = await loop.run_in_executor(None, check_for_updates)
return {"available": available}
@app.post("/api/reboot")
async def api_reboot():
try:
await asyncio.create_subprocess_exec(*REBOOT_COMMAND)
except Exception:
raise HTTPException(status_code=500, detail="Failed to initiate reboot")
return {"ok": True}
async def api_updates_run():
async def event_stream() -> AsyncIterator[str]:
yield "data: $ ssh root@localhost 'cd /etc/nixos && nix flake update && nixos-rebuild switch && flatpak update -y'\n\n"
yield "data: \n\n"
process = await asyncio.create_subprocess_exec(
*UPDATE_COMMAND,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
assert process.stdout is not None
try:
async for raw_line in process.stdout:
line = raw_line.decode(errors="replace").rstrip("\n")
# SSE requires data: prefix; escape newlines within a line
yield f"data: {line}\n\n"
except Exception:
yield "data: [stream error: output read interrupted]\n\n"
await process.wait()
if process.returncode == 0:
yield "event: done\ndata: success\n\n"
else:
yield f"event: error\ndata: exit code {process.returncode}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,408 @@
/* Sovran_SystemsOS Hub — Vanilla JS Frontend */
"use strict";
const POLL_INTERVAL_SERVICES = 5000; // 5 s
const POLL_INTERVAL_UPDATES = 1800000; // 30 min
const ACTION_REFRESH_DELAY = 1500; // 1.5 s after start/stop/restart
const CATEGORY_ORDER = [
"infrastructure",
"bitcoin-base",
"bitcoin-apps",
"communication",
"apps",
"nostr",
];
const STATUS_LOADING_STATES = new Set([
"reloading", "activating", "deactivating", "maintenance",
]);
// ── State ─────────────────────────────────────────────────────────
let _servicesCache = [];
let _categoryLabels = {};
let _updateSource = null;
let _updateLog = "";
// ── DOM refs ──────────────────────────────────────────────────────
const $tilesArea = document.getElementById("tiles-area");
const $updateBtn = document.getElementById("btn-update");
const $updateBadge = document.getElementById("update-badge");
const $refreshBtn = document.getElementById("btn-refresh");
const $internalIp = document.getElementById("ip-internal");
const $externalIp = document.getElementById("ip-external");
const $modal = document.getElementById("update-modal");
const $modalSpinner = document.getElementById("modal-spinner");
const $modalStatus = document.getElementById("modal-status");
const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report");
const $btnCloseModal = document.getElementById("btn-close-modal");
// ── Helpers ───────────────────────────────────────────────────────
function statusClass(status) {
if (!status) return "unknown";
if (status === "active") return "active";
if (status === "inactive") return "inactive";
if (status === "failed") return "failed";
if (status === "disabled") return "disabled";
if (STATUS_LOADING_STATES.has(status)) return "loading";
return "unknown";
}
function statusText(status, enabled) {
if (!enabled) return "disabled";
if (!status || status === "unknown") return "unknown";
return status;
}
// ── Fetch wrappers ────────────────────────────────────────────────
async function apiFetch(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
// ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) {
_servicesCache = services;
// Group by category
const grouped = {};
for (const svc of services) {
const cat = svc.category || "other";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc);
}
$tilesArea.innerHTML = "";
const orderedKeys = [
...CATEGORY_ORDER.filter(k => grouped[k]),
...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)),
];
for (const catKey of orderedKeys) {
const entries = grouped[catKey];
if (!entries || entries.length === 0) continue;
const label = categoryLabels[catKey] || catKey;
const section = document.createElement("div");
section.className = "category-section";
section.dataset.category = catKey;
section.innerHTML = `
<div class="section-header">${escHtml(label)}</div>
<hr class="section-divider" />
<div class="tiles-grid" data-cat="${escHtml(catKey)}"></div>
`;
const grid = section.querySelector(".tiles-grid");
for (const svc of entries) {
grid.appendChild(buildTile(svc));
}
$tilesArea.appendChild(section);
}
if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = `<div class="empty-state"><p>No services configured.</p></div>`;
}
}
function buildTile(svc) {
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dis = !svc.enabled;
const isOn = svc.status === "active";
const tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "");
tile.dataset.unit = svc.unit;
if (dis) tile.title = `${svc.name} is not enabled in custom.nix`;
tile.innerHTML = `
<img class="tile-icon"
src="/static/icons/${escHtml(svc.icon)}.svg"
alt="${escHtml(svc.name)}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div class="tile-icon-fallback" style="display:none">⚙</div>
<div class="tile-name">${escHtml(svc.name)}</div>
<div class="tile-status">
<span class="status-dot ${sc}"></span>
<span class="status-text">${escHtml(st)}</span>
</div>
<div class="tile-spacer"></div>
<div class="tile-controls">
<label class="toggle-label${dis ? " disabled-toggle" : ""}" title="${dis ? "Not enabled in custom.nix" : (isOn ? "Stop" : "Start")}">
<input type="checkbox" class="tile-toggle" data-unit="${escHtml(svc.unit)}"
${isOn ? "checked" : ""} ${dis ? "disabled" : ""}>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<button class="tile-restart-btn" data-unit="${escHtml(svc.unit)}"
title="Restart" ${dis ? "disabled" : ""}>↺</button>
</div>
`;
// Toggle handler
const chk = tile.querySelector(".tile-toggle");
if (!dis) {
chk.addEventListener("change", async (e) => {
const action = e.target.checked ? "start" : "stop";
chk.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
// Restart handler
const restartBtn = tile.querySelector(".tile-restart-btn");
if (!dis) {
restartBtn.addEventListener("click", async () => {
restartBtn.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
return tile;
}
// ── Render: live update (no DOM rebuild) ──────────────────────────
function updateTiles(services) {
_servicesCache = services;
for (const svc of services) {
const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`);
if (!tile) continue;
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dot = tile.querySelector(".status-dot");
const text = tile.querySelector(".status-text");
const chk = tile.querySelector(".tile-toggle");
if (dot) { dot.className = `status-dot ${sc}`; }
if (text) { text.textContent = st; }
if (chk && !chk.disabled) {
chk.checked = svc.status === "active";
}
}
}
// ── HTML escape ───────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── Service polling ───────────────────────────────────────────────
let _firstLoad = true;
async function refreshServices() {
try {
const services = await apiFetch("/api/services");
if (_firstLoad) {
buildTiles(services, _categoryLabels);
_firstLoad = false;
} else {
updateTiles(services);
}
} catch (err) {
console.warn("Failed to fetch services:", err);
}
}
// ── Network IPs ───────────────────────────────────────────────────
async function loadNetwork() {
try {
const data = await apiFetch("/api/network");
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
} catch (_) {
if ($internalIp) $internalIp.textContent = "—";
if ($externalIp) $externalIp.textContent = "—";
}
}
// ── Update check ──────────────────────────────────────────────────
async function checkUpdates() {
try {
const data = await apiFetch("/api/updates/check");
if ($updateBadge) {
$updateBadge.classList.toggle("visible", !!data.available);
}
} catch (_) {}
}
// ── Update modal ──────────────────────────────────────────────────
function openUpdateModal() {
if (!$modal) return;
_updateLog = "";
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Updating…";
if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) { $btnReboot.style.display = "none"; }
if ($btnSave) { $btnSave.style.display = "none"; }
if ($btnCloseModal) { $btnCloseModal.disabled = true; }
$modal.classList.add("open");
startUpdateStream();
}
function closeUpdateModal() {
if (!$modal) return;
$modal.classList.remove("open");
if (_updateSource) {
_updateSource.close();
_updateSource = null;
}
}
function appendLog(text) {
_updateLog += text + "\n";
if ($modalLog) {
$modalLog.textContent += text + "\n";
$modalLog.scrollTop = $modalLog.scrollHeight;
}
}
function startUpdateStream() {
// Trigger the update via POST first, then listen via SSE
fetch("/api/updates/run", { method: "POST" }).then(response => {
if (!response.ok || !response.body) {
const detail = response.ok ? "no body" : `HTTP ${response.status} ${response.statusText}`;
appendLog(`[Error: failed to start update — ${detail}]`);
onUpdateDone(false);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function read() {
reader.read().then(({ done, value }) => {
if (done) {
onUpdateDone(true);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep incomplete line
for (const line of lines) {
if (line.startsWith("data: ")) {
appendLog(line.slice(6));
} else if (line.startsWith("event: done")) {
// success event will follow in data:
} else if (line.startsWith("event: error")) {
// error event will follow in data:
}
}
read();
}).catch(err => {
appendLog(`[Stream error: ${err}]`);
onUpdateDone(false);
});
}
read();
}).catch(err => {
appendLog(`[Request error: ${err}]`);
onUpdateDone(false);
});
}
function onUpdateDone(success) {
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (success) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
if ($btnSave) $btnSave.style.display = "inline-flex";
}
}
function saveErrorReport() {
const blob = new Blob([_updateLog], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `sovran-update-error-${new Date().toISOString().split('.')[0].replace(/:/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function doReboot() {
fetch("/api/reboot", { method: "POST" }).catch(() => {});
if ($modalStatus) $modalStatus.textContent = "Rebooting…";
}
// ── Event listeners ───────────────────────────────────────────────
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices());
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
// Close modal on overlay click
if ($modal) {
$modal.addEventListener("click", (e) => {
if (e.target === $modal) closeUpdateModal();
});
}
// ── Init ──────────────────────────────────────────────────────────
async function init() {
// Load config to get category labels
try {
const cfg = await apiFetch("/api/config");
if (cfg.category_order) {
for (const [key, label] of cfg.category_order) {
_categoryLabels[key] = label;
}
}
// Update role badge
const badge = document.getElementById("role-badge");
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
} catch (_) {}
// Initial data loads
await refreshServices();
loadNetwork();
checkUpdates();
// Polling
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -0,0 +1,530 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme matching the Adwaita dark aesthetic */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-color: #1e1e2e;
--surface-color: #2a2a3c;
--card-color: #313244;
--border-color: #45475a;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-dim: #6c7086;
--accent-color: #89b4fa;
--green: #2ec27e;
--yellow: #e5a50a;
--red: #e01b24;
--grey: #888888;
--radius-card: 18px;
--radius-btn: 8px;
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
}
html, body {
height: 100%;
}
body {
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
/* ── Header bar ─────────────────────────────────────────────────── */
.header-bar {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: 10px 24px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
.header-bar .title {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
flex: 1;
}
.role-badge {
background-color: var(--accent-color);
color: #1e1e2e;
font-size: 0.72rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.03em;
}
/* ── Buttons ────────────────────────────────────────────────────── */
button {
font-family: inherit;
cursor: pointer;
border: none;
outline: none;
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
.btn {
padding: 7px 16px;
border-radius: var(--radius-btn);
font-size: 0.88rem;
font-weight: 600;
}
.btn-primary {
background-color: var(--accent-color);
color: #1e1e2e;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-update {
background-color: var(--green);
color: #fff;
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.btn-update:hover:not(:disabled) {
background-color: #27ae6e;
}
.update-badge {
display: none;
width: 10px;
height: 10px;
background-color: var(--yellow);
border-radius: 50%;
animation: pulse-badge 1.4s ease-in-out infinite;
}
.update-badge.visible {
display: inline-block;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.35); }
}
.btn-icon {
background: none;
color: var(--text-secondary);
padding: 6px;
border-radius: 50%;
font-size: 1.1rem;
line-height: 1;
}
.btn-icon:hover:not(:disabled) {
background-color: var(--border-color);
color: var(--text-primary);
}
/* ── IP bar ─────────────────────────────────────────────────────── */
.ip-bar {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: 8px 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
font-size: 0.82rem;
color: var(--text-secondary);
}
.ip-bar .ip-label {
color: var(--text-dim);
margin-right: 6px;
}
.ip-bar .ip-value {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
color: var(--accent-color);
font-weight: 600;
}
.ip-separator {
color: var(--border-color);
}
/* ── Main content ───────────────────────────────────────────────── */
.main-content {
max-width: 980px;
margin: 0 auto;
padding: 24px 16px 48px;
}
/* ── Category sections ──────────────────────────────────────────── */
.category-section {
margin-bottom: 32px;
}
.section-header {
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
padding-left: 4px;
}
.section-divider {
border: none;
border-top: 1px solid var(--border-color);
margin-bottom: 16px;
}
.tiles-grid {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
/* ── Service tile card ──────────────────────────────────────────── */
.service-tile {
width: 180px;
min-height: 210px;
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
align-items: center;
padding: 18px 12px 14px;
gap: 0;
transition: box-shadow 0.2s, border-color 0.2s;
position: relative;
}
.service-tile:hover {
box-shadow: var(--shadow-hover);
border-color: #6c7086;
}
.service-tile.disabled {
opacity: 0.45;
}
.tile-icon {
width: 48px;
height: 48px;
object-fit: contain;
margin-bottom: 8px;
}
.tile-icon-fallback {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--border-color);
border-radius: 12px;
color: var(--text-dim);
font-size: 1.5rem;
margin-bottom: 8px;
}
.tile-name {
font-size: 0.88rem;
font-weight: 600;
text-align: center;
color: var(--text-primary);
line-height: 1.3;
max-width: 156px;
word-break: break-word;
hyphens: auto;
min-height: 2.6em;
display: flex;
align-items: center;
justify-content: center;
}
.tile-status {
font-size: 0.75rem;
margin-top: 6px;
display: flex;
align-items: center;
gap: 5px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--grey);
}
.status-dot.active { background-color: var(--green); }
.status-dot.inactive { background-color: var(--red); }
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
.status-dot.failed { background-color: var(--red); }
.status-dot.disabled { background-color: var(--grey); }
.tile-spacer {
flex: 1;
}
/* ── Tile controls ──────────────────────────────────────────────── */
.tile-controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
/* CSS-only toggle switch */
.toggle-label {
display: flex;
align-items: center;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
display: none;
}
.toggle-track {
width: 40px;
height: 22px;
background-color: var(--border-color);
border-radius: 11px;
position: relative;
transition: background-color 0.2s;
}
.toggle-label input:checked + .toggle-track {
background-color: var(--green);
}
.toggle-label.disabled-toggle {
cursor: not-allowed;
opacity: 0.5;
}
.toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
background-color: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.toggle-label input:checked + .toggle-track .toggle-thumb {
transform: translateX(18px);
}
.tile-restart-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 6px;
border-radius: 50%;
font-size: 0.95rem;
line-height: 1;
transition: background-color 0.15s, color 0.15s;
}
.tile-restart-btn:hover:not(:disabled) {
background-color: var(--border-color);
color: var(--text-primary);
}
.tile-restart-btn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── Update modal ───────────────────────────────────────────────── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.65);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal-dialog {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 16px;
width: 90vw;
max-width: 900px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
}
.modal-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
gap: 12px;
}
.modal-title {
font-size: 1rem;
font-weight: 700;
flex: 1;
}
.modal-status {
font-size: 0.85rem;
color: var(--text-secondary);
}
.modal-spinner {
width: 18px;
height: 18px;
border: 2.5px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.75s linear infinite;
display: none;
}
.modal-spinner.spinning {
display: block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal-log {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.78rem;
line-height: 1.6;
color: var(--text-primary);
background-color: #12121c;
white-space: pre-wrap;
word-break: break-all;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--border-color);
}
.btn-reboot {
background-color: var(--red);
color: #fff;
}
.btn-reboot:hover:not(:disabled) {
background-color: #c0181f;
}
.btn-save {
background-color: var(--yellow);
color: #1e1e2e;
}
.btn-save:hover:not(:disabled) {
background-color: #c98d08;
}
.btn-close-modal {
background-color: var(--border-color);
color: var(--text-primary);
}
.btn-close-modal:hover:not(:disabled) {
background-color: #5a5c72;
}
/* ── Empty state ────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 64px 24px;
color: var(--text-dim);
}
.empty-state p {
font-size: 1rem;
margin-bottom: 8px;
}
/* ── Responsive ─────────────────────────────────────────────────── */
@media (max-width: 600px) {
.header-bar {
padding: 10px 14px;
gap: 10px;
}
.header-bar .title {
font-size: 0.95rem;
}
.ip-bar {
gap: 16px;
flex-wrap: wrap;
padding: 8px 14px;
}
.main-content {
padding: 16px 12px 40px;
}
.tiles-grid {
justify-content: center;
}
.service-tile {
width: 160px;
min-height: 200px;
}
}

View File

@@ -34,7 +34,7 @@ def run_action(
else:
cmd = base_cmd
try:
subprocess.Popen(cmd)
return True
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return result.returncode == 0
except Exception:
return False

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- Header bar -->
<header class="header-bar">
<span class="title">Sovran_SystemsOS Hub</span>
<span class="role-badge" id="role-badge">Loading…</span>
<button class="btn btn-update" id="btn-update" title="Run system update">
<span class="update-badge" id="update-badge"></span>
Update System
</button>
<button class="btn-icon" id="btn-refresh" title="Refresh service status"></button>
</header>
<!-- IP bar -->
<div class="ip-bar">
<span>
<span class="ip-label">Internal IP:</span>
<span class="ip-value" id="ip-internal"></span>
</span>
<span class="ip-separator">|</span>
<span>
<span class="ip-label">External IP:</span>
<span class="ip-value" id="ip-external"></span>
</span>
</div>
<!-- Service tiles -->
<main class="main-content">
<div id="tiles-area"></div>
</main>
<!-- Update modal -->
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
<div class="modal-dialog">
<div class="modal-header">
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
<div class="modal-spinner" id="modal-spinner"></div>
<span class="modal-status" id="modal-status">Updating…</span>
</div>
<div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -1,69 +0,0 @@
/* ── Tile (locked dimensions via GTK min-width/height only) ── */
.sovran-tile {
border-radius: 18px;
padding: 0px;
min-width: 180px;
min-height: 210px;
transition: box-shadow 200ms ease-in-out;
}
.sovran-tile:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
/* ── Tile text ─────────────────────────────────────────────── */
.tile-name {
font-weight: bold;
}
.tile-status {
}
/* ── Section headers ───────────────────────────────────────── */
.section-header {
font-weight: bold;
}
/* ── Status colors ─────────────────────────────────────────── */
.success { color: #2ec27e; }
.warning { color: #e5a50a; }
.error { color: #e01b24; }
.disabled-label { color: #888888; font-style: italic; }
/* ── Header / role ─────────────────────────────────────────── */
.hub-title {
font-weight: bold;
}
.role-badge {
padding: 2px 8px;
border-radius: 4px;
}
/* ── Update indicator ──────────────────────────────────────── */
.update-badge {
color: #2ec27e;
font-weight: bold;
}
.update-available {
background: #2ec27e;
color: white;
}
.update-available:hover {
background: #26a269;
}
/* ── IP bar ────────────────────────────────────────────────── */
.ip-bar {
padding: 10px 20px;
border-radius: 10px;
background: alpha(@card_bg_color, 0.5);
}
.ip-value {
font-family: monospace;
font-weight: bold;
color: @accent_color;
}
/* ── Grid container ────────────────────────────────────────── */
.tiles-container {
margin-left: auto;
margin-right: auto;
}

View File

@@ -52,27 +52,18 @@ let
services = monitoredServices;
});
sovran-hub = pkgs.python3Packages.buildPythonApplication {
pname = "sovran-systemsos-hub";
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
pname = "sovran-systemsos-hub-web";
version = "1.0.0";
format = "other";
src = ../../app;
nativeBuildInputs = with pkgs; [
wrapGAppsHook4
gobject-introspection
];
buildInputs = with pkgs; [
gtk4
libadwaita
gdk-pixbuf
librsvg
];
propagatedBuildInputs = with pkgs.python3Packages; [
pygobject3
fastapi
uvicorn
jinja2
python-multipart
];
dontBuild = true;
@@ -81,84 +72,64 @@ let
runHook preInstall
# Python source
install -d $out/lib/sovran-hub
cp -r sovran_systemsos_hub $out/lib/sovran-hub/
# CSS
cp style.css $out/lib/sovran-hub/style.css
install -d $out/lib/sovran-hub-web
cp -r sovran_systemsos_web $out/lib/sovran-hub-web/
# Generated config
cp ${generatedConfig} $out/lib/sovran-hub/config.json
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
# Icons (SVG + PNG) <EFBFBD><EFBFBD><EFBFBD>
# Icons (SVG)
install -d $out/share/sovran-hub/icons
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
# Launcher script
install -d $out/bin
cat > $out/bin/sovran-hub <<LAUNCHER
cat > $out/bin/sovran-hub-web <<LAUNCHER
#!${pkgs.python3}/bin/python3
import os, sys
base = os.path.join("$out", "lib", "sovran-hub")
base = os.path.join("$out", "lib", "sovran-hub-web")
sys.path.insert(0, base)
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
os.environ["SOVRAN_HUB_CSS"] = os.path.join(base, "style.css")
from sovran_systemsos_hub.application import SovranHubApp
sys.exit(SovranHubApp().run(sys.argv))
import uvicorn
uvicorn.run(
"sovran_systemsos_web.server:app",
host="0.0.0.0",
port=8080,
log_level="info",
)
LAUNCHER
chmod +x $out/bin/sovran-hub
# Desktop file (for app launcher + dock)
install -d $out/share/applications
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Name=Sovran_SystemsOS Hub
Comment=Manage Sovran_SystemsOS systemd services
Exec=$out/bin/sovran-hub
Icon=utilities-system-monitor-symbolic
Terminal=false
Categories=System;Monitor;
StartupWMClass=com.sovransystems.hub
DESKTOP
# Autostart desktop file
install -d $out/etc/xdg/autostart
cat > $out/etc/xdg/autostart/sovran-hub.desktop <<AUTOSTART
[Desktop Entry]
Type=Application
Name=Sovran_SystemsOS Hub
Comment=Manage Sovran_SystemsOS systemd services
Exec=$out/bin/sovran-hub
Icon=utilities-system-monitor-symbolic
Terminal=false
X-GNOME-Autostart-enabled=true
AutostartCondition=unless-exists sovran-hub-no-autostart
AUTOSTART
chmod +x $out/bin/sovran-hub-web
runHook postInstall
'';
meta = {
description = "Sovran_SystemsOS Hub GTK4 systemd service manager";
mainProgram = "sovran-hub";
description = "Sovran_SystemsOS Hub web-based systemd service manager";
mainProgram = "sovran-hub-web";
};
};
in
{
config = {
environment.systemPackages = [ sovran-hub ];
# ── Web server as a systemd service ────────────────────────
systemd.services.sovran-hub-web = {
description = "Sovran_SystemsOS Hub Web Interface";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# ── XDG autostart: link the system-wide autostart file ─────
environment.etc."xdg/autostart/sovran-hub.desktop".source =
"${sovran-hub}/etc/xdg/autostart/sovran-hub.desktop";
serviceConfig = {
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
Restart = "on-failure";
RestartSec = "5s";
User = "root";
StandardOutput = "journal";
StandardError = "journal";
};
};
# ── GNOME dock: add to favorites ───────────────────────────
services.xserver.desktopManager.gnome.extraGSettingsOverrides = ''
[org.gnome.shell]
favorite-apps=['org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Console.desktop', 'firefox.desktop']
'';
# ── Open firewall port ─────────────────────────────────────
networking.firewall.allowedTCPPorts = [ 8080 ];
};
}