updated look of hub

This commit is contained in:
2026-03-31 10:30:11 -05:00
parent 690ea64df6
commit f342f6d286
4 changed files with 362 additions and 42 deletions

27
app/app_style.css Normal file
View File

@@ -0,0 +1,27 @@
/* Sovran_SystemsOS Hub — tile styling */
.sovran-tile {
border-radius: 16px;
padding: 8px;
min-width: 140px;
min-height: 160px;
transition: box-shadow 200ms ease-in-out;
}
.sovran-tile:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.sovran-tile-icon {
margin-top: 8px;
margin-bottom: 2px;
}
/* Status dot colors */
.success {
color: #2ec27e;
}
.warning {
color: #e5a50a;
}

View File

@@ -2,31 +2,44 @@
from __future__ import annotations from __future__ import annotations
import os
import gi import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1") gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402 from gi.repository import Adw, Gdk, Gio, GLib, Gtk # noqa: E402
from .config import load_config # noqa: E402 from .config import load_config # noqa: E402
from .service_row import ServiceRow # noqa: E402 from .service_tile import ServiceTile # noqa: E402
APP_ID = "com.sovransystems.hub" APP_ID = "com.sovransystems.hub"
class SovranHubWindow(Adw.ApplicationWindow): class SovranHubWindow(Adw.ApplicationWindow):
"""Primary window: a list of service rows with auto-refresh.""" """Primary window: a 4-across grid of service tiles with auto-refresh."""
def __init__(self, app: Adw.Application, config: dict): def __init__(self, app: Adw.Application, config: dict):
super().__init__( super().__init__(
application=app, application=app,
title="Sovran_SystemsOS Hub", title="Sovran_SystemsOS Hub",
default_width=560, default_width=680,
default_height=620, default_height=700,
) )
self._config = config self._config = config
self._rows: list[ServiceRow] = [] self._tiles: list[ServiceTile] = []
# ── Load custom CSS ──
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 bar ── # ── Header bar ──
header = Adw.HeaderBar() header = Adw.HeaderBar()
@@ -38,37 +51,56 @@ class SovranHubWindow(Adw.ApplicationWindow):
refresh_btn.connect("clicked", lambda _b: self._refresh_all()) refresh_btn.connect("clicked", lambda _b: self._refresh_all())
header.pack_end(refresh_btn) header.pack_end(refresh_btn)
# ── Content ── # ── FlowBox: 4 tiles across ──
self._group = Adw.PreferencesGroup(title="Services") self._flowbox = Gtk.FlowBox(
max_children_per_line=4,
min_children_per_line=2,
selection_mode=Gtk.SelectionMode.NONE,
homogeneous=True,
row_spacing=12,
column_spacing=12,
margin_top=16,
margin_bottom=16,
margin_start=16,
margin_end=16,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
)
page = Adw.PreferencesPage() # ── Scrollable content ──
page.add(self._group) scrolled = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
vexpand=True,
child=self._flowbox,
)
toolbar_view = Adw.ToolbarView() toolbar_view = Adw.ToolbarView()
toolbar_view.add_top_bar(header) toolbar_view.add_top_bar(header)
toolbar_view.set_content(page) toolbar_view.set_content(scrolled)
self.set_content(toolbar_view) self.set_content(toolbar_view)
# ── Populate ── # ── Populate ──
self._build_rows() self._build_tiles()
self._start_auto_refresh() self._start_auto_refresh()
def _build_rows(self): def _build_tiles(self):
"""Create ServiceRow widgets from config.""" """Create ServiceTile widgets from config."""
for entry in self._config.get("services", []): for entry in self._config.get("services", []):
row = ServiceRow( tile = ServiceTile(
name=entry.get("name", entry["unit"]), name=entry.get("name", entry["unit"]),
unit=entry["unit"], unit=entry["unit"],
scope=entry.get("type", "system"), scope=entry.get("type", "system"),
method=self._config.get("command_method", "systemctl"), method=self._config.get("command_method", "systemctl"),
icon_name=entry.get("icon", ""),
) )
self._group.add(row) self._flowbox.append(tile)
self._rows.append(row) self._tiles.append(tile)
def _refresh_all(self): def _refresh_all(self):
for row in self._rows: for tile in self._tiles:
row.refresh() tile.refresh()
def _start_auto_refresh(self): def _start_auto_refresh(self):
interval = self._config.get("refresh_interval", 5) interval = self._config.get("refresh_interval", 5)
@@ -77,7 +109,7 @@ class SovranHubWindow(Adw.ApplicationWindow):
def _auto_refresh_cb(self) -> bool: def _auto_refresh_cb(self) -> bool:
self._refresh_all() self._refresh_all()
return True # keep the timer alive return True
class SovranHubApp(Adw.Application): class SovranHubApp(Adw.Application):

View File

@@ -0,0 +1,165 @@
"""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 Adw, Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
from . import systemctl # noqa: E402
LOADING_STATES = {"reloading", "activating", "deactivating", "maintenance"}
# Icon directory injected by the Nix derivation via environment variable
ICON_DIR = os.environ.get("SOVRAN_HUB_ICONS", "")
class ServiceTile(Gtk.Box):
"""A square tile showing a service logo, name, status, toggle, and restart."""
def __init__(
self,
name: str,
unit: str,
scope: str = "system",
method: str = "systemctl",
icon_name: str = "",
**kwargs,
):
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
spacing=6,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
width_request=140,
height_request=160,
css_classes=["card", "sovran-tile"],
margin_top=6,
margin_bottom=6,
margin_start=6,
margin_end=6,
**kwargs,
)
self._unit = unit
self._scope = scope
self._method = method
self._name = name
# ── Logo ──
self._logo = Gtk.Image(
pixel_size=48,
margin_top=12,
halign=Gtk.Align.CENTER,
css_classes=["sovran-tile-icon"],
)
self._set_logo(icon_name)
self.append(self._logo)
# ── Service name ──
label = Gtk.Label(
label=name,
css_classes=["heading"],
halign=Gtk.Align.CENTER,
ellipsize=3, # PANGO_ELLIPSIZE_END
max_width_chars=14,
)
self.append(label)
# ── Status label ──
self._status_label = Gtk.Label(
css_classes=["caption"],
halign=Gtk.Align.CENTER,
)
self.append(self._status_label)
# ── Controls row (switch + restart) ──
controls = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=8,
halign=Gtk.Align.CENTER,
margin_bottom=8,
)
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)
# Initial state
self.refresh()
def _set_logo(self, icon_name: str):
"""Set the tile logo from a PNG in the icons dir, or fall back to a symbolic icon."""
if icon_name and ICON_DIR:
png_path = os.path.join(ICON_DIR, f"{icon_name}.png")
if os.path.isfile(png_path):
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
png_path, 48, 48, True
)
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
self._logo.set_from_paintable(texture)
return
# Fallback: themed symbolic icon
self._logo.set_from_icon_name("system-run-symbolic")
# ── public API ──
def refresh(self):
"""Poll systemctl and update the tile."""
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
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 + color
if is_failed:
self._status_label.set_label("● failed")
self._status_label.set_css_classes(["caption", "error"])
elif is_active:
self._status_label.set_label("● running")
self._status_label.set_css_classes(["caption", "success"])
elif is_loading:
self._status_label.set_label(f"{active_state}")
self._status_label.set_css_classes(["caption", "warning"])
else:
self._status_label.set_label(f"{active_state}")
self._status_label.set_css_classes(["caption", "dim-label"])
# ── 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)
GLib.timeout_add(1500, self.refresh)
return False
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,50 +1,137 @@
# modules/core/sovran-hub.nix # modules/core/sovran-hub.nix
# #
# Declarative NixOS module that: # Declarative NixOS module that:
# 1. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation # 1. Fetches high-quality PNG logos for each service
# 2. Generates its config.json from existing sovran_systemsOS options # 2. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation
# 3. Installs a .desktop file so it appears in GNOME Activities # 3. Generates its config.json from existing sovran_systemsOS options
# 4. Installs a .desktop file so it appears in GNOME Activities
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
cfg = config.sovran_systemsOS; cfg = config.sovran_systemsOS;
# ── Fetch service logos ──────────────────────────────────────
#
# Each logo is fetched once at build time and placed in a
# single directory as <icon-key>.png so the Python app can
# load them by name.
logos = {
bitcoind = pkgs.fetchurl {
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Bitcoin.svg/240px-Bitcoin.svg.png";
sha256 = "0000000000000000000000000000000000000000000000000000"; # replace after first build
name = "bitcoind.png";
};
electrs = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nicehash/electrumx-client/master/electrum-logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "electrs.png";
};
lnd = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/lightningnetwork/lnd/master/logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "lnd.png";
};
rtl = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/Ride-The-Lightning/RTL/master/src/assets/images/rtl-logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "rtl.png";
};
btcpayserver = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "btcpayserver.png";
};
synapse = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nicehash/element-web/develop/res/themes/element/img/logos/element-logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "synapse.png";
};
vaultwarden = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/resources/vaultwarden-icon.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "vaultwarden.png";
};
nextcloud = pkgs.fetchurl {
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Nextcloud_Logo.svg/240px-Nextcloud_Logo.svg.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "nextcloud.png";
};
wordpress = pkgs.fetchurl {
url = "https://s.w.org/style/images/about/WordPress-logotype-wmark.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "wordpress.png";
};
haven = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nicehash/nostr-rs-relay/master/logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "haven.png";
};
mempool = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/nicehash/mempool/master/frontend/src/resources/mempool-space-logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "mempool.png";
};
livekit = pkgs.fetchurl {
url = "https://avatars.githubusercontent.com/u/70location?s=200";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "livekit.png";
};
caddy = pkgs.fetchurl {
url = "https://caddyserver.com/resources/images/caddy-logo.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "caddy.png";
};
tor = pkgs.fetchurl {
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/1/15/Tor-logo-2011-flat.svg/240px-Tor-logo-2011-flat.svg.png";
sha256 = "0000000000000000000000000000000000000000000000000000";
name = "tor.png";
};
};
# Bundle all logos into a single derivation directory
logoDir = pkgs.runCommand "sovran-hub-icons" {} ''
mkdir -p $out
${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: src:
"cp ${src} $out/${key}.png"
) logos)}
'';
# ── Build the list of monitored units from NixOS option state ── # ── Build the list of monitored units from NixOS option state ──
# #
# The JSON config is computed at NixOS evaluation time from the # Each entry now includes an "icon" key that matches a filename
# SAME options that control whether the services actually run. # in the logoDir (without extension).
# No separate config file to maintain.
monitoredServices = monitoredServices =
(lib.optional cfg.services.bitcoin (lib.optional cfg.services.bitcoin
{ name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; }) { name = "Bitcoind"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; })
++ (lib.optional cfg.services.bitcoin ++ (lib.optional cfg.services.bitcoin
{ name = "Electrs"; unit = "electrs.service"; type = "system"; }) { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; })
++ (lib.optional cfg.services.bitcoin ++ (lib.optional cfg.services.bitcoin
{ name = "LND"; unit = "lnd.service"; type = "system"; }) { name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; })
++ (lib.optional cfg.services.bitcoin ++ (lib.optional cfg.services.bitcoin
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; }) { name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; })
++ (lib.optional cfg.services.bitcoin ++ (lib.optional cfg.services.bitcoin
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; }) { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; })
++ (lib.optional cfg.services.synapse ++ (lib.optional cfg.services.synapse
{ name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; }) { name = "Matrix-Synapse"; unit = "matrix-synapse.service"; type = "system"; icon = "synapse"; })
++ (lib.optional cfg.services.vaultwarden ++ (lib.optional cfg.services.vaultwarden
{ name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; }) { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; })
++ (lib.optional cfg.services.nextcloud ++ (lib.optional cfg.services.nextcloud
{ name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; }) { name = "Nextcloud"; unit = "phpfpm-nextcloud.service"; type = "system"; icon = "nextcloud"; })
++ (lib.optional cfg.services.wordpress ++ (lib.optional cfg.services.wordpress
{ name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; }) { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; })
++ (lib.optional cfg.features.haven ++ (lib.optional cfg.features.haven
{ name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; }) { name = "Haven Relay"; unit = "haven-relay.service"; type = "system"; icon = "haven"; })
++ (lib.optional cfg.features.mempool ++ (lib.optional cfg.features.mempool
{ name = "Mempool"; unit = "mempool.service"; type = "system"; }) { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; })
++ (lib.optional cfg.features.element-calling ++ (lib.optional cfg.features.element-calling
{ name = "LiveKit"; unit = "livekit.service"; type = "system"; }) { name = "LiveKit"; unit = "livekit.service"; type = "system"; icon = "livekit"; })
# Always-on infrastructure # Always-on infrastructure
++ [ ++ [
{ name = "Caddy"; unit = "caddy.service"; type = "system"; } { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; }
{ name = "Tor"; unit = "tor.service"; type = "system"; } { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; }
]; ];
# ── Generate the config.json at build time ── # ── Generate the config.json at build time ──
@@ -69,6 +156,7 @@ let
pkgs.gtk4 pkgs.gtk4
pkgs.libadwaita pkgs.libadwaita
pkgs.gobject-introspection pkgs.gobject-introspection
pkgs.gdk-pixbuf
]; ];
propagatedBuildInputs = [ propagatedBuildInputs = [
@@ -78,20 +166,28 @@ let
dontBuild = true; dontBuild = true;
installPhase = '' installPhase = ''
mkdir -p $out/bin $out/lib/sovran-hub $out/share/applications mkdir -p $out/bin $out/lib/sovran-hub $out/share/applications $out/share/sovran-hub/icons
# Copy Python source # Copy Python source
cp -r sovran_systemsos_hub $out/lib/sovran-hub/ cp -r sovran_systemsos_hub $out/lib/sovran-hub/
# Install the generated config (from Nix options, not a static file) # Copy CSS
cp style.css $out/lib/sovran-hub/style.css
# Install the generated config
cp ${generatedConfig} $out/lib/sovran-hub/config.json cp ${generatedConfig} $out/lib/sovran-hub/config.json
# Install logos
cp -r ${logoDir}/* $out/share/sovran-hub/icons/
# Create the launcher script # Create the launcher script
cat > $out/bin/sovran-hub <<'LAUNCHER' cat > $out/bin/sovran-hub <<'LAUNCHER'
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys, os import sys, os
sys.path.insert(0, os.path.join("@out@", "lib", "sovran-hub")) sys.path.insert(0, os.path.join("@out@", "lib", "sovran-hub"))
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join("@out@", "lib", "sovran-hub", "config.json") os.environ["SOVRAN_HUB_CONFIG"] = os.path.join("@out@", "lib", "sovran-hub", "config.json")
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("@out@", "share", "sovran-hub", "icons")
os.environ["SOVRAN_HUB_CSS"] = os.path.join("@out@", "lib", "sovran-hub", "style.css")
from sovran_systemsos_hub.application import SovranHubApp from sovran_systemsos_hub.application import SovranHubApp
sys.exit(SovranHubApp().run(sys.argv)) sys.exit(SovranHubApp().run(sys.argv))
LAUNCHER LAUNCHER