susops 3.0.0rc3.dev7__tar.gz → 3.0.0rc3.dev9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/PKG-INFO +1 -1
  2. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/pyproject.toml +7 -2
  3. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/client.py +18 -0
  4. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/rpc_server.py +3 -0
  5. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/facade.py +59 -13
  6. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tray/base.py +9 -1
  7. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/app.py +10 -3
  8. susops-3.0.0rc3.dev9/src/susops/tui/app.tcss +63 -0
  9. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/dashboard.py +24 -4
  10. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/PKG-INFO +1 -1
  11. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/SOURCES.txt +1 -0
  12. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/LICENSE +0 -0
  13. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/README.md +0 -0
  14. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/setup.cfg +0 -0
  15. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/__init__.py +0 -0
  16. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icon.png +0 -0
  17. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/error.svg +0 -0
  18. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/running.svg +0 -0
  19. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/stopped.svg +0 -0
  20. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/dark/stopped_partially.svg +0 -0
  21. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/error.svg +0 -0
  22. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/running.svg +0 -0
  23. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/stopped.svg +0 -0
  24. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_glasses/light/stopped_partially.svg +0 -0
  25. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/error.svg +0 -0
  26. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/running.svg +0 -0
  27. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/stopped.svg +0 -0
  28. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/dark/stopped_partially.svg +0 -0
  29. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/error.svg +0 -0
  30. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/running.svg +0 -0
  31. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/stopped.svg +0 -0
  32. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/colored_s/light/stopped_partially.svg +0 -0
  33. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/error.svg +0 -0
  34. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/running.svg +0 -0
  35. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/stopped.svg +0 -0
  36. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/dark/stopped_partially.svg +0 -0
  37. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/error.svg +0 -0
  38. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/running.svg +0 -0
  39. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/stopped.svg +0 -0
  40. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/gear/light/stopped_partially.svg +0 -0
  41. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/error.svg +0 -0
  42. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/running.svg +0 -0
  43. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/stopped.svg +0 -0
  44. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/assets/icons/status/stopped_partially.svg +0 -0
  45. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/__init__.py +0 -0
  46. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/browsers.py +0 -0
  47. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/config.py +0 -0
  48. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/log_style.py +0 -0
  49. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/pac.py +0 -0
  50. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/ports.py +0 -0
  51. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/process.py +0 -0
  52. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/rpc_protocol.py +0 -0
  53. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/services_daemon.py +0 -0
  54. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/share.py +0 -0
  55. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/socat.py +0 -0
  56. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/ssh.py +0 -0
  57. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/ssh_config.py +0 -0
  58. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/status.py +0 -0
  59. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/core/types.py +0 -0
  60. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tray/__init__.py +0 -0
  61. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tray/linux.py +0 -0
  62. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tray/mac.py +0 -0
  63. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/__init__.py +0 -0
  64. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/__main__.py +0 -0
  65. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/cli.py +0 -0
  66. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/__init__.py +0 -0
  67. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/connections.py +0 -0
  68. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/screens/shares.py +0 -0
  69. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/widgets/__init__.py +0 -0
  70. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/tui/widgets/connection_card.py +0 -0
  71. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops/version.py +0 -0
  72. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/dependency_links.txt +0 -0
  73. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/entry_points.txt +0 -0
  74. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/requires.txt +0 -0
  75. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/src/susops.egg-info/top_level.txt +0 -0
  76. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_bw_totals.py +0 -0
  77. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_cli.py +0 -0
  78. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_client.py +0 -0
  79. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_config.py +0 -0
  80. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_conftest_smoke.py +0 -0
  81. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_facade.py +0 -0
  82. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_openapi.py +0 -0
  83. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_pac.py +0 -0
  84. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_packaging.py +0 -0
  85. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_process.py +0 -0
  86. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_rpc_protocol.py +0 -0
  87. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_rpc_server.py +0 -0
  88. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_scripts.py +0 -0
  89. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_services_daemon.py +0 -0
  90. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_share.py +0 -0
  91. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_share_aiohttp.py +0 -0
  92. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_socat.py +0 -0
  93. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_ssh.py +0 -0
  94. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_status.py +0 -0
  95. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_update_homebrew_sha.py +0 -0
  96. {susops-3.0.0rc3.dev7 → susops-3.0.0rc3.dev9}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc3.dev7
3
+ Version: 3.0.0rc3.dev9
4
4
  Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "susops"
7
- version = "3.0.0-rc3.dev7"
7
+ version = "3.0.0-rc3.dev9"
8
8
  description = "SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -51,7 +51,12 @@ susops-tray = "susops.tray:main"
51
51
  where = ["src"]
52
52
 
53
53
  [tool.setuptools.package-data]
54
- susops = ["assets/*.png", "assets/icons/**/*.svg", "assets/icons/**/*.png"]
54
+ susops = [
55
+ "assets/*.png",
56
+ "assets/icons/**/*.svg",
57
+ "assets/icons/**/*.png",
58
+ "tui/**/*.tcss",
59
+ ]
55
60
 
56
61
  [tool.pytest.ini_options]
57
62
  testpaths = ["tests"]
@@ -69,6 +69,24 @@ def ensure_daemon_running(workspace: Path = _WORKSPACE_DEFAULT) -> int:
69
69
  port = _read_port(workspace)
70
70
  if port:
71
71
  return port
72
+ # Daemon claimed its PID file but hasn't published the port yet.
73
+ # Wait for it instead of spawning a competitor that would race the
74
+ # O_EXCL claim and exit rc=2 ("another daemon is already running").
75
+ deadline = time.monotonic() + _DAEMON_SPAWN_TIMEOUT
76
+ while time.monotonic() < deadline:
77
+ if not _is_daemon_alive(workspace):
78
+ break # died mid-startup, fall through to spawn a new one
79
+ port = _read_port(workspace)
80
+ if port:
81
+ return port
82
+ time.sleep(0.1)
83
+ else:
84
+ raise DaemonUnavailableError(
85
+ "An existing susops-services daemon is alive but never "
86
+ "published an RPC port. Try `kill " +
87
+ (_pid_path(workspace).read_text().strip() or "<pid>") +
88
+ "` and start again."
89
+ )
72
90
 
73
91
  proc = subprocess.Popen(
74
92
  [sys.executable, "-m", "susops.core.services_daemon",
@@ -45,6 +45,9 @@ _ALLOWED_METHODS: set[str] = {
45
45
  "get_pac_url", "get_status_url",
46
46
  # Bandwidth
47
47
  "get_bandwidth", "get_bandwidth_totals", "get_bandwidth_global",
48
+ "get_bandwidth_history",
49
+ # Frontend coordination
50
+ "sse_client_count",
48
51
  # Reconnect introspection
49
52
  "reconnect_monitor_info",
50
53
  # Process introspection
@@ -376,6 +376,11 @@ class _BandwidthSampler:
376
376
  with self._lock:
377
377
  return self._totals.get(tag, (0.0, 0.0))
378
378
 
379
+ def get_history(self, tag: str) -> list[list[float]]:
380
+ """Return the persisted (rx_bps, tx_bps) samples for *tag* (newest last)."""
381
+ with self._lock:
382
+ return list(self._history.get(tag, []))
383
+
379
384
  def reset_totals(self, tag: str | None = None) -> None:
380
385
  """Reset cumulative counters. Pass tag=None to reset all."""
381
386
  with self._lock:
@@ -620,26 +625,54 @@ class SusOpsManager:
620
625
  print(full, file=sys.stderr)
621
626
 
622
627
  def _notify(self, title: str, body: str) -> None:
623
- """Send a desktop notification. Best-effort — fails silently."""
628
+ """Send a desktop notification with the SusOps icon. Best-effort."""
624
629
  import platform
625
- import subprocess
630
+ from pathlib import Path
631
+ icon = Path(__file__).parent / "assets" / "icon.png"
626
632
  try:
627
633
  if platform.system() == "Darwin":
628
- subprocess.Popen(
629
- ["osascript", "-e",
630
- f'display notification "{body}" with title "{title}"'],
631
- stdout=subprocess.DEVNULL,
632
- stderr=subprocess.DEVNULL,
633
- )
634
+ self._notify_macos(title, body, icon)
634
635
  elif platform.system() == "Linux":
635
- subprocess.Popen(
636
- ["notify-send", title, body],
637
- stdout=subprocess.DEVNULL,
638
- stderr=subprocess.DEVNULL,
639
- )
636
+ self._notify_linux(title, body, icon)
640
637
  except Exception:
641
638
  pass
642
639
 
640
+ @staticmethod
641
+ def _notify_macos(title: str, body: str, icon: "Path") -> None:
642
+ # NSUserNotification's setContentImage_ puts the SusOps icon on the
643
+ # right side of the banner. The source-app slot (left side) reflects
644
+ # the running process — bundled .app shows SusOps, raw pip install
645
+ # shows Python. osascript can't set either, so it loses both.
646
+ try:
647
+ from Foundation import NSUserNotification, NSUserNotificationCenter # type: ignore
648
+ from AppKit import NSImage # type: ignore
649
+ n = NSUserNotification.alloc().init()
650
+ n.setTitle_(title)
651
+ n.setInformativeText_(body)
652
+ if icon.exists():
653
+ img = NSImage.alloc().initWithContentsOfFile_(str(icon))
654
+ if img is not None:
655
+ n.setContentImage_(img)
656
+ NSUserNotificationCenter.defaultUserNotificationCenter().deliverNotification_(n)
657
+ return
658
+ except Exception:
659
+ pass
660
+ # Fallback for non-PyObjC environments
661
+ import subprocess
662
+ subprocess.Popen(
663
+ ["osascript", "-e", f'display notification "{body}" with title "{title}"'],
664
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
665
+ )
666
+
667
+ @staticmethod
668
+ def _notify_linux(title: str, body: str, icon: "Path") -> None:
669
+ import subprocess
670
+ cmd = ["notify-send"]
671
+ if icon.exists():
672
+ cmd += ["-i", str(icon)]
673
+ cmd += [title, body]
674
+ subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
675
+
643
676
  def _emit(self, event: str, data: dict) -> None:
644
677
  """Emit an SSE event and log it when verbose (bandwidth excluded — too noisy)."""
645
678
  self._status_server.emit(event, data)
@@ -2143,6 +2176,19 @@ class SusOpsManager:
2143
2176
  """Return cumulative (rx_bytes, tx_bytes) since last start. Resets on stop."""
2144
2177
  return self._bw_sampler.get_totals(tag)
2145
2178
 
2179
+ def get_bandwidth_history(self, tag: str) -> list[list[float]]:
2180
+ """Return persisted [rx_bps, tx_bps] samples for *tag* (oldest → newest)."""
2181
+ return self._bw_sampler.get_history(tag)
2182
+
2183
+ def sse_client_count(self) -> int:
2184
+ """Live SSE subscriber count. Used by frontends to decide whether
2185
+ a stop-on-quit should actually stop, or skip because another
2186
+ frontend is still attached to the same daemon."""
2187
+ try:
2188
+ return int(self._status_server.client_count())
2189
+ except Exception:
2190
+ return 0
2191
+
2146
2192
  def get_bandwidth_global(self) -> tuple[float, float]:
2147
2193
  """Return (rx_bps, tx_bps) summed across every connection."""
2148
2194
  rx_total = 0.0
@@ -641,7 +641,15 @@ class AbstractTrayApp(ABC):
641
641
 
642
642
  def do_quit(self) -> None:
643
643
  if self.manager.app_config.stop_on_quit:
644
- self.manager.stop()
644
+ # Skip the destructive stop when another frontend (e.g. TUI)
645
+ # is still attached, otherwise it ends up with its SSH
646
+ # masters / PAC / reconnect torn out from under it.
647
+ try:
648
+ other_clients = int(self.manager.sse_client_count()) - 1
649
+ except Exception:
650
+ other_clients = 0
651
+ if other_clients <= 0:
652
+ self.manager.stop()
645
653
  # else: the daemon is a separate process — it keeps running with
646
654
  # PAC server, status SSE, and reconnect monitor independent of the
647
655
  # tray's lifetime. No detach calls needed.
@@ -121,9 +121,16 @@ class SusOpsTuiApp(App):
121
121
 
122
122
  def action_quit(self) -> None:
123
123
  if self.manager.app_config.stop_on_quit:
124
- # Stops SSH tunnels + share servers in the daemon; PAC stays up
125
- # via stopped-marker tombstones unless the user reset.
126
- self.manager.stop_quick()
124
+ # Skip the destructive stop when another frontend (e.g. tray)
125
+ # is still attached. Otherwise the still-running frontend
126
+ # finds its SSH masters / PAC / reconnect monitor torn out
127
+ # from under it and looks broken until it's restarted.
128
+ try:
129
+ other_clients = int(self.manager.sse_client_count()) - 1
130
+ except Exception:
131
+ other_clients = 0
132
+ if other_clients <= 0:
133
+ self.manager.stop_quick()
127
134
  # No detach calls — the daemon is already a separate process and
128
135
  # outlives the TUI.
129
136
  self.exit()
@@ -0,0 +1,63 @@
1
+ /* SusOps TUI — global theme */
2
+
3
+ /* ── Base ── */
4
+ Screen { background: $background; }
5
+ Header { background: $primary-darken-3; }
6
+ Footer { background: $surface-darken-2; }
7
+ .footer-row { dock: bottom; height: 1; }
8
+ .footer-row Footer { dock: none; width: 1fr; height: 1; }
9
+ .footer-version { width: auto; padding: 0 1; background: $surface-darken-2; color: $text-muted; content-align: center middle; }
10
+ .footer-edit-config { width: auto; padding: 0 1; background: $surface-darken-2; color: $accent; content-align: center middle; }
11
+ .footer-logo { width: auto; padding: 0 1; background: $surface-darken-2; content-align: center middle; }
12
+
13
+ /* ── Dashboard ── */
14
+ #conn-list > ListItem { height: 1; padding: 0 1; }
15
+
16
+ /* ── Connection editor ── */
17
+ .tab-actions { height: 3; layout: horizontal; padding: 0 1; background: $surface-darken-1; }
18
+ .tab-actions Button { margin-right: 1; }
19
+ #detail-preview { height: 6; border: round $primary-darken-1; border-title-align: left; padding: 0 1; margin: 0 1 1 1; color: $text-muted; }
20
+
21
+ /* ── Share ── */
22
+ #share-toolbar { height: 3; layout: horizontal; padding: 0 1; background: $surface-darken-1; }
23
+ #share-toolbar Button { margin-right: 1; }
24
+ #share-list-panel { width: 35; background: $surface-darken-1; border-right: solid $primary-darken-2; }
25
+ #share-list { height: 1fr; margin: 1; border: round $primary-darken-1; border-title-align: left; }
26
+ #share-list > ListItem { height: 1; padding: 0 1; }
27
+ #share-detail { width: 1fr; padding: 1 2; height: 1fr; }
28
+ #share-status { height: 1; padding: 0 1; background: $surface-darken-2; color: $text-muted; }
29
+
30
+ /* ── Logs ── */
31
+ #log-toolbar { height: 3; layout: horizontal; padding: 0 1; background: $surface-darken-1; align: left middle; }
32
+ #log-toolbar Label { margin-right: 1; height: 1; }
33
+ #log-toolbar Switch { margin-right: 2; }
34
+ #log-toolbar Button { margin-left: 2; }
35
+ #log-view { height: 1fr; border: round $primary-darken-1; border-title-align: left; margin: 0 1 1 1; }
36
+
37
+ /* ── Config ── */
38
+ #config-toolbar { height: 3; layout: horizontal; padding: 0 1; background: $surface-darken-1; }
39
+ #config-toolbar Button { margin-right: 1; }
40
+ #config-area { height: 1fr; border: round $primary-darken-1; border-title-align: left; margin: 0 1 1 1; }
41
+
42
+ /* ── PAC viewer ── */
43
+ #pac-area { height: 1fr; border: round $primary-darken-1; border-title-align: left; margin: 0 1 1 1; }
44
+
45
+ /* ── Modals ── */
46
+ ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.75); }
47
+ .modal-dialog { width: 76; height: auto; border: round $primary; padding: 1 2; background: $surface; }
48
+ .modal-dialog Label { margin-top: 1; }
49
+ .modal-dialog > Label:first-child { margin-top: 0; }
50
+ .modal-dialog Input { margin-bottom: 1; }
51
+ .modal-dialog Select { margin-bottom: 1; }
52
+ .modal-dialog .modal-error { color: $error; margin-top: 0; }
53
+ .modal-dialog .modal-hint { margin-top: 0; }
54
+ .modal-btn-row { layout: horizontal; height: auto; margin-top: 1; }
55
+ .modal-btn-row Button { margin-right: 1; }
56
+ /* 2-column form rows */
57
+ .modal-form-row { height: auto; margin-top: 1; }
58
+ .modal-proto-row { height: auto; }
59
+ .modal-field { width: 1fr; height: auto; layout: vertical; padding-right: 1; }
60
+ .modal-field:last-child { padding-right: 0; }
61
+ .modal-field Label { margin-top: 0; }
62
+ .modal-field Input { margin-bottom: 0; }
63
+ .modal-field Select { margin-bottom: 0; }
@@ -290,11 +290,19 @@ class DashboardScreen(Screen):
290
290
  bw: dict[str, tuple[float, float]] = {}
291
291
  bw_totals: dict[str, tuple[float, float]] = {}
292
292
  uptimes: dict[str, float | None] = {}
293
+ bw_history_seed: dict[str, list] = {}
293
294
  for cs in result.connection_statuses:
294
295
  extras[cs.tag] = mgr.get_process_info(cs.tag)
295
296
  bw[cs.tag] = mgr.get_bandwidth(cs.tag)
296
297
  bw_totals[cs.tag] = mgr.get_bandwidth_totals(cs.tag)
297
298
  uptimes[cs.tag] = mgr.get_uptime(cs.tag)
299
+ # Only fetch persisted history for tags we haven't seeded
300
+ # yet — saves an RPC per tick once the chart is rolling.
301
+ if cs.tag not in self._rx_history:
302
+ try:
303
+ bw_history_seed[cs.tag] = mgr.get_bandwidth_history(cs.tag) or []
304
+ except Exception:
305
+ bw_history_seed[cs.tag] = []
298
306
 
299
307
  shares = mgr.list_shares()
300
308
  config = mgr.list_config()
@@ -312,7 +320,8 @@ class DashboardScreen(Screen):
312
320
  )
313
321
  return
314
322
  self.app.call_from_thread(
315
- self._apply_status, result, extras, bw, bw_totals, uptimes, shares, config, reconnect
323
+ self._apply_status, result, extras, bw, bw_totals, uptimes, shares,
324
+ config, reconnect, bw_history_seed,
316
325
  )
317
326
 
318
327
  def _apply_status(
@@ -325,7 +334,9 @@ class DashboardScreen(Screen):
325
334
  shares: list,
326
335
  config,
327
336
  reconnect: dict,
337
+ bw_history_seed: dict | None = None,
328
338
  ) -> None:
339
+ bw_history_seed = bw_history_seed or {}
329
340
  self._last_config = config
330
341
  self._last_shares = shares
331
342
 
@@ -352,13 +363,22 @@ class DashboardScreen(Screen):
352
363
  }
353
364
  self._conn_data = new_conn_data
354
365
 
355
- # Update rolling bandwidth history (60 samples)
366
+ # Update rolling bandwidth history (60 samples). Seed from the
367
+ # daemon's persisted history (pre-fetched by refresh_status) on
368
+ # first encounter so reopening the TUI doesn't reset the chart to
369
+ # flatlined zeros.
356
370
  for cs in result.connection_statuses:
357
371
  tag = cs.tag
358
372
  rx, tx = bw.get(tag, (0.0, 0.0))
359
373
  if tag not in self._rx_history:
360
- self._rx_history[tag] = deque([0.0] * 60, maxlen=60)
361
- self._tx_history[tag] = deque([0.0] * 60, maxlen=60)
374
+ rx_seed = [0.0] * 60
375
+ tx_seed = [0.0] * 60
376
+ persisted = (bw_history_seed.get(tag) or [])[-60:]
377
+ if persisted:
378
+ rx_seed[-len(persisted):] = [s[0] for s in persisted]
379
+ tx_seed[-len(persisted):] = [s[1] for s in persisted]
380
+ self._rx_history[tag] = deque(rx_seed, maxlen=60)
381
+ self._tx_history[tag] = deque(tx_seed, maxlen=60)
362
382
  self._rx_history[tag].append(rx)
363
383
  self._tx_history[tag].append(tx)
364
384
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: susops
3
- Version: 3.0.0rc3.dev7
3
+ Version: 3.0.0rc3.dev9
4
4
  Summary: SusOps — SSH SOCKS5 proxy manager with PAC server, Textual TUI, and system tray apps
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -63,6 +63,7 @@ src/susops/tray/mac.py
63
63
  src/susops/tui/__init__.py
64
64
  src/susops/tui/__main__.py
65
65
  src/susops/tui/app.py
66
+ src/susops/tui/app.tcss
66
67
  src/susops/tui/cli.py
67
68
  src/susops/tui/screens/__init__.py
68
69
  src/susops/tui/screens/connections.py
File without changes
File without changes
File without changes