From f342f6d28664b349d215f3da5e73822996122947 Mon Sep 17 00:00:00 2001 From: naturallaw77 Date: Tue, 31 Mar 2026 10:30:11 -0500 Subject: [PATCH] updated look of hub --- app/app_style.css | 27 ++++ app/sovran_systemsos_hub/application.py | 72 +++++++--- app/sovran_systemsos_hub/service_tile.py | 165 +++++++++++++++++++++++ modules/core/sovran-hub.nix | 140 ++++++++++++++++--- 4 files changed, 362 insertions(+), 42 deletions(-) create mode 100644 app/app_style.css create mode 100644 app/sovran_systemsos_hub/service_tile.py diff --git a/app/app_style.css b/app/app_style.css new file mode 100644 index 0000000..0934be3 --- /dev/null +++ b/app/app_style.css @@ -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; +} \ No newline at end of file diff --git a/app/sovran_systemsos_hub/application.py b/app/sovran_systemsos_hub/application.py index 72a4df2..9241dff 100644 --- a/app/sovran_systemsos_hub/application.py +++ b/app/sovran_systemsos_hub/application.py @@ -2,31 +2,44 @@ from __future__ import annotations +import os + import gi gi.require_version("Gtk", "4.0") 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 .service_row import ServiceRow # noqa: E402 +from .service_tile import ServiceTile # noqa: E402 APP_ID = "com.sovransystems.hub" 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): super().__init__( application=app, title="Sovran_SystemsOS Hub", - default_width=560, - default_height=620, + default_width=680, + default_height=700, ) 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 = Adw.HeaderBar() @@ -38,37 +51,56 @@ class SovranHubWindow(Adw.ApplicationWindow): refresh_btn.connect("clicked", lambda _b: self._refresh_all()) header.pack_end(refresh_btn) - # ── Content ── - self._group = Adw.PreferencesGroup(title="Services") + # ── FlowBox: 4 tiles across ── + 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() - page.add(self._group) + # ── Scrollable content ── + scrolled = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + vexpand=True, + child=self._flowbox, + ) toolbar_view = Adw.ToolbarView() toolbar_view.add_top_bar(header) - toolbar_view.set_content(page) + toolbar_view.set_content(scrolled) self.set_content(toolbar_view) # ── Populate ── - self._build_rows() + self._build_tiles() self._start_auto_refresh() - def _build_rows(self): - """Create ServiceRow widgets from config.""" + def _build_tiles(self): + """Create ServiceTile widgets from config.""" for entry in self._config.get("services", []): - row = ServiceRow( + tile = ServiceTile( name=entry.get("name", entry["unit"]), unit=entry["unit"], scope=entry.get("type", "system"), method=self._config.get("command_method", "systemctl"), + icon_name=entry.get("icon", ""), ) - self._group.add(row) - self._rows.append(row) + self._flowbox.append(tile) + self._tiles.append(tile) def _refresh_all(self): - for row in self._rows: - row.refresh() + for tile in self._tiles: + tile.refresh() def _start_auto_refresh(self): interval = self._config.get("refresh_interval", 5) @@ -77,7 +109,7 @@ class SovranHubWindow(Adw.ApplicationWindow): def _auto_refresh_cb(self) -> bool: self._refresh_all() - return True # keep the timer alive + return True class SovranHubApp(Adw.Application): diff --git a/app/sovran_systemsos_hub/service_tile.py b/app/sovran_systemsos_hub/service_tile.py new file mode 100644 index 0000000..faa7786 --- /dev/null +++ b/app/sovran_systemsos_hub/service_tile.py @@ -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) \ No newline at end of file diff --git a/modules/core/sovran-hub.nix b/modules/core/sovran-hub.nix index e692376..158890c 100644 --- a/modules/core/sovran-hub.nix +++ b/modules/core/sovran-hub.nix @@ -1,50 +1,137 @@ # modules/core/sovran-hub.nix # # Declarative NixOS module that: -# 1. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation -# 2. Generates its config.json from existing sovran_systemsOS options -# 3. Installs a .desktop file so it appears in GNOME Activities +# 1. Fetches high-quality PNG logos for each service +# 2. Builds the Sovran_SystemsOS_Hub GTK4 app as a Nix derivation +# 3. Generates its config.json from existing sovran_systemsOS options +# 4. Installs a .desktop file so it appears in GNOME Activities { config, pkgs, lib, ... }: let cfg = config.sovran_systemsOS; + # ── Fetch service logos ────────────────────────────────────── + # + # Each logo is fetched once at build time and placed in a + # single directory as .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 ── # - # The JSON config is computed at NixOS evaluation time from the - # SAME options that control whether the services actually run. - # No separate config file to maintain. + # Each entry now includes an "icon" key that matches a filename + # in the logoDir (without extension). monitoredServices = (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 - { name = "Electrs"; unit = "electrs.service"; type = "system"; }) + { name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; }) ++ (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 - { 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 - { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; }) + { name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; }) ++ (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 - { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; }) + { name = "VaultWarden"; unit = "vaultwarden.service"; type = "system"; icon = "vaultwarden"; }) ++ (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 - { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; }) + { name = "WordPress"; unit = "phpfpm-wordpress.service"; type = "system"; icon = "wordpress"; }) ++ (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 - { name = "Mempool"; unit = "mempool.service"; type = "system"; }) + { name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; }) ++ (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 ++ [ - { name = "Caddy"; unit = "caddy.service"; type = "system"; } - { name = "Tor"; unit = "tor.service"; type = "system"; } + { name = "Caddy"; unit = "caddy.service"; type = "system"; icon = "caddy"; } + { name = "Tor"; unit = "tor.service"; type = "system"; icon = "tor"; } ]; # ── Generate the config.json at build time ── @@ -69,6 +156,7 @@ let pkgs.gtk4 pkgs.libadwaita pkgs.gobject-introspection + pkgs.gdk-pixbuf ]; propagatedBuildInputs = [ @@ -78,20 +166,28 @@ let dontBuild = true; 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 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 + # Install logos + cp -r ${logoDir}/* $out/share/sovran-hub/icons/ + # Create the launcher script cat > $out/bin/sovran-hub <<'LAUNCHER' #!/usr/bin/env python3 import sys, os 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_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 sys.exit(SovranHubApp().run(sys.argv)) LAUNCHER