sing-box-cli 0.0.22__tar.gz → 0.0.24__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 (29) hide show
  1. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/CHANGELOG.md +16 -0
  2. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/PKG-INFO +2 -1
  3. sing_box_cli-0.0.24/assets/image.png +0 -0
  4. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/pyproject.toml +3 -1
  5. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/__init__.py +1 -1
  6. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/monitor.py +307 -109
  7. sing_box_cli-0.0.24/src/sing_box_service/widget.py +64 -0
  8. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/uv.lock +41 -2
  9. sing_box_cli-0.0.22/assets/image.png +0 -0
  10. sing_box_cli-0.0.22/src/sing_box_service/widget.py +0 -28
  11. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.github/dependabot.yml +0 -0
  12. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.github/workflows/main.yml +0 -0
  13. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.gitignore +0 -0
  14. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.pre-commit-config.yaml +0 -0
  15. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/LICENSE +0 -0
  16. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/README.md +0 -0
  17. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/bump.sh +0 -0
  18. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/format.sh +0 -0
  19. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/lint.sh +0 -0
  20. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/pre-commit.sh +0 -0
  21. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/client.py +0 -0
  22. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/config.py +0 -0
  23. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/connections.py +0 -0
  24. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/main.py +0 -0
  25. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/policy.py +0 -0
  26. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/py.typed +0 -0
  27. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/run.py +0 -0
  28. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/service.py +0 -0
  29. {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/tests/test_config.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.0.24 - 2025-03-19
6
+
7
+ ### 🚜 Refactor
8
+
9
+ - Smooth traffic data
10
+
11
+ ## 0.0.23 - 2025-03-19
12
+
13
+ ### 🚀 Features
14
+
15
+ - Replace connection table with traffic graph in stats command
16
+
17
+ ### 📚 Documentation
18
+
19
+ - Update README.md command --help
20
+
5
21
  ## 0.0.22 - 2025-03-18
6
22
 
7
23
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sing-box-cli
3
- Version: 0.0.22
3
+ Version: 0.0.24
4
4
  Summary: cross-platform sing-box-service manager.
5
5
  Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
6
6
  Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
@@ -39,6 +39,7 @@ Classifier: Programming Language :: Python :: 3.12
39
39
  Classifier: Programming Language :: Python :: 3.13
40
40
  Requires-Python: >=3.10
41
41
  Requires-Dist: httpx>=0.28.1
42
+ Requires-Dist: plotext>=5.3.2
42
43
  Requires-Dist: prompt-toolkit>=3.0.50
43
44
  Requires-Dist: psutil>=6.1.1
44
45
  Requires-Dist: typer>=0.15.1
Binary file
@@ -25,6 +25,7 @@ maintainers = [{ name = "A.J.Zeller", email = "zhouge1831@gmail.com" }]
25
25
  dynamic = ["version"] # https://hatch.pypa.io/latest/config/metadata/#version
26
26
  dependencies = [
27
27
  "httpx>=0.28.1",
28
+ "plotext>=5.3.2",
28
29
  "prompt-toolkit>=3.0.50",
29
30
  "psutil>=6.1.1",
30
31
  "typer>=0.15.1",
@@ -59,6 +60,7 @@ dev = [
59
60
  "typos>=1.26.8",
60
61
  "types-psutil>=6.1.0.20241221",
61
62
  "pytest>=8.3.5",
63
+ "scipy-stubs>=1.15.2.1",
62
64
  ]
63
65
 
64
66
  [tool.hatch.version]
@@ -210,7 +212,7 @@ sort_commits = "oldest"
210
212
 
211
213
 
212
214
  [tool.bumpversion]
213
- current_version = "0.0.22"
215
+ current_version = "0.0.24"
214
216
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
215
217
  serialize = ["{major}.{minor}.{patch}"]
216
218
  search = "{current_version}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.0.22"
5
+ __version__ = "0.0.24"
@@ -1,19 +1,21 @@
1
1
  import asyncio
2
+ import math
2
3
  import statistics
3
4
  from collections import deque
4
5
  from datetime import datetime
5
6
  from enum import Enum
6
7
  from typing import Any
7
8
 
8
- from rich import box
9
- from rich.console import Console, Group
9
+ import plotext as plt # type: ignore
10
+ from rich.console import Console
10
11
  from rich.layout import Layout
11
12
  from rich.live import Live
12
13
  from rich.panel import Panel
13
14
  from rich.table import Table
14
15
  from rich.text import Text
15
16
 
16
- from sing_box_service.client import SingBoxAPIClient
17
+ from .client import SingBoxAPIClient
18
+ from .widget import RichPlotMixin, create_header_panel
17
19
 
18
20
 
19
21
  class RefreshRate(Enum):
@@ -193,6 +195,280 @@ def format_chain(chains: list[str]) -> str:
193
195
  raise ValueError(f"Invalid chains data: {chains}")
194
196
 
195
197
 
198
+ def advanced_smooth_bezier(
199
+ data: list[float], tension: float = 1, points_per_segment: int = 50
200
+ ) -> tuple[list[float], list[float]]:
201
+ """
202
+ Create a highly smooth curve from data using advanced Bezier techniques.
203
+
204
+ Args:
205
+ data: Original data points
206
+ tension: Controls curve tension (0.0-1.0, higher = smoother curves)
207
+ points_per_segment: Number of points to generate between each original data point
208
+
209
+ Returns:
210
+ Tuple of (x_values, y_values) containing smoothed curve points
211
+ """
212
+ if len(data) < 3:
213
+ return list(range(len(data))), data
214
+
215
+ # Create original x values
216
+ x_orig = list(range(len(data)))
217
+ y_orig = data
218
+
219
+ # Pre-process data to avoid extreme jumps
220
+ # Apply light smoothing to input data first
221
+ smoothed_input = []
222
+ window_size = 3
223
+
224
+ for i in range(len(y_orig)):
225
+ # Use window averaging for initial smoothing
226
+ window_start = max(0, i - window_size // 2)
227
+ window_end = min(len(y_orig), i + window_size // 2 + 1)
228
+ window = y_orig[window_start:window_end]
229
+ # Weighted average with center point having more influence
230
+ weights = [
231
+ 0.5 + 0.5 * (1 - abs(i - j) / (window_size / 2))
232
+ for j in range(window_start, window_end)
233
+ ]
234
+ smoothed_input.append(
235
+ sum(w * v for w, v in zip(weights, window, strict=True)) / sum(weights)
236
+ )
237
+
238
+ # Replace original data with pre-smoothed data
239
+ y_orig = smoothed_input
240
+
241
+ x_smooth = []
242
+ y_smooth = []
243
+
244
+ # Calculate control points for the entire curve first
245
+ control_points_x1 = []
246
+ control_points_y1 = []
247
+ control_points_x2 = []
248
+ control_points_y2 = []
249
+
250
+ for i in range(len(data)):
251
+ # For each point, calculate control points
252
+ if i == 0: # First point
253
+ # Control points for first segment
254
+ ctrl_x1 = x_orig[i]
255
+ ctrl_y1 = y_orig[i]
256
+
257
+ # Direction to next point
258
+ dx = x_orig[i + 1] - x_orig[i]
259
+ dy = y_orig[i + 1] - y_orig[i]
260
+
261
+ ctrl_x2 = x_orig[i] + dx * tension / 2
262
+ ctrl_y2 = y_orig[i] + dy * tension / 2
263
+
264
+ elif i == len(data) - 1: # Last point
265
+ # Control points for last segment
266
+ # Direction from previous point
267
+ dx = x_orig[i] - x_orig[i - 1]
268
+ dy = y_orig[i] - y_orig[i - 1]
269
+
270
+ ctrl_x1 = int(x_orig[i] - dx * tension / 2)
271
+ ctrl_y1 = int(y_orig[i] - dy * tension / 2)
272
+
273
+ ctrl_x2 = x_orig[i]
274
+ ctrl_y2 = y_orig[i]
275
+
276
+ else: # Middle points
277
+ # Calculate slopes of adjacent segments
278
+ dx1 = x_orig[i] - x_orig[i - 1]
279
+ dy1 = y_orig[i] - y_orig[i - 1]
280
+ dx2 = x_orig[i + 1] - x_orig[i]
281
+ dy2 = y_orig[i + 1] - y_orig[i]
282
+
283
+ # Average slope, weighted by segment length
284
+ len1 = (dx1**2 + dy1**2) ** 0.5
285
+ len2 = (dx2**2 + dy2**2) ** 0.5
286
+ total_len = len1 + len2
287
+
288
+ # Normalized direction vectors
289
+ if len1 > 0:
290
+ nx1, ny1 = dx1 / len1, dy1 / len1
291
+ else:
292
+ nx1, ny1 = 0, 0
293
+
294
+ if len2 > 0:
295
+ nx2, ny2 = dx2 / len2, dy2 / len2
296
+ else:
297
+ nx2, ny2 = 0, 0
298
+
299
+ # Weighted average direction
300
+ avg_nx = (nx1 * len1 + nx2 * len2) / total_len if total_len > 0 else 0
301
+ avg_ny = (ny1 * len1 + ny2 * len2) / total_len if total_len > 0 else 0
302
+
303
+ # Scale to create control points
304
+ scale = min(len1, len2) * tension
305
+
306
+ # Set control points
307
+ ctrl_x1 = x_orig[i] - avg_nx * scale
308
+ ctrl_y1 = y_orig[i] - avg_ny * scale
309
+ ctrl_x2 = x_orig[i] + avg_nx * scale
310
+ ctrl_y2 = y_orig[i] + avg_ny * scale
311
+
312
+ control_points_x1.append(ctrl_x1)
313
+ control_points_y1.append(ctrl_y1)
314
+ control_points_x2.append(ctrl_x2)
315
+ control_points_y2.append(ctrl_y2)
316
+
317
+ # Generate the curve points using the control points
318
+ for i in range(len(data) - 1):
319
+ # Start and end points
320
+ x0, y0 = x_orig[i], y_orig[i]
321
+ x1, y1 = x_orig[i + 1], y_orig[i + 1]
322
+
323
+ # Control points for this segment
324
+ cx1, cy1 = control_points_x2[i], control_points_y2[i]
325
+ cx2, cy2 = control_points_x1[i + 1], control_points_y1[i + 1]
326
+
327
+ # Generate points along this Bezier segment
328
+ for t in range(points_per_segment + 1):
329
+ t_norm = t / points_per_segment
330
+
331
+ # Cubic Bezier formula
332
+ x_point = (
333
+ (1 - t_norm) ** 3 * x0
334
+ + 3 * (1 - t_norm) ** 2 * t_norm * cx1
335
+ + 3 * (1 - t_norm) * t_norm**2 * cx2
336
+ + t_norm**3 * x1
337
+ )
338
+ y_point = (
339
+ (1 - t_norm) ** 3 * y0
340
+ + 3 * (1 - t_norm) ** 2 * t_norm * cy1
341
+ + 3 * (1 - t_norm) * t_norm**2 * cy2
342
+ + t_norm**3 * y1
343
+ )
344
+
345
+ x_smooth.append(x_point)
346
+ y_smooth.append(y_point)
347
+
348
+ return x_smooth, y_smooth
349
+
350
+
351
+ class TrafficGraph(RichPlotMixin):
352
+ """A Rich-compatible network traffic graph using plotext."""
353
+
354
+ def __init__(self, max_points: int = 60) -> None:
355
+ """
356
+ Initialize the traffic graph.
357
+
358
+ Args:
359
+ max_points: Maximum number of data points to keep
360
+ """
361
+ super().__init__()
362
+
363
+ self.max_points = max_points
364
+ # Initialize data structures for storing history
365
+ # At least 2 points needed for a line o avoid rendering issues
366
+ self.upload_speeds: deque[float] = deque([0.0, 0.0], maxlen=max_points)
367
+ self.download_speeds: deque[float] = deque([0.0, 0.0], maxlen=max_points)
368
+
369
+ # Number of points to generate during inte= 5
370
+ self.interp_factor = 5
371
+
372
+ def update_from_traffic_data(self, traffic_data: dict[str, Any]) -> None:
373
+ """
374
+ Update the graph with new traffic data.
375
+
376
+ Args:
377
+ traffic_data: Dictionary with 'up' and 'down' traffic totals
378
+ """
379
+
380
+ # Update the graph with new data
381
+ self.upload_speeds.append(traffic_data.get("up", 0))
382
+ self.download_speeds.append(traffic_data.get("down", 0))
383
+
384
+ def make_plot(self, width: int, height: int, tension: float = 0.8) -> str:
385
+ """
386
+ Create the plot with highly smoothed curves.
387
+
388
+ Args:
389
+ width: Width of the plot
390
+ height: Height of the plot
391
+ tension: Controls curve smoothness (0.0-1.0)
392
+
393
+ Returns:
394
+ String representation of the plot
395
+ """
396
+ # Clear the previous plot
397
+ plt.clf()
398
+
399
+ # Set up the plot styling
400
+ plt.theme("dark")
401
+ plt.axes_color((10, 14, 27))
402
+ plt.ticks_color((133, 159, 213))
403
+ plt.plotsize(width, height)
404
+
405
+ # Set titles and legend
406
+ plt.xlabel("Time")
407
+ plt.ylabel("Speed")
408
+ plt.grid(False) # Disable grid for cleaner look
409
+
410
+ # Get the data for plotting
411
+ y_upload = list(self.upload_speeds)
412
+ y_download = list(self.download_speeds)
413
+
414
+ if len(y_upload) >= 3 and len(y_download) >= 3:
415
+ # Apply advanced smoothing
416
+ x_upload, y_upload_smooth = advanced_smooth_bezier(
417
+ y_upload, tension=tension, points_per_segment=30
418
+ )
419
+ x_download, y_download_smooth = advanced_smooth_bezier(
420
+ y_download, tension=tension, points_per_segment=30
421
+ )
422
+
423
+ # Fill areas below curves
424
+ plt.plot(
425
+ x_upload,
426
+ y_upload_smooth,
427
+ marker=".",
428
+ label="Upload",
429
+ color=(255, 73, 112), # Red
430
+ )
431
+ plt.plot(
432
+ x_download,
433
+ y_download_smooth,
434
+ marker=".",
435
+ label="Download",
436
+ color=(68, 180, 255), # Blue color like in the image
437
+ )
438
+
439
+ # Calculate and set y-axis ticks
440
+ all_y_values = y_upload_smooth + y_download_smooth
441
+ max_y_value = max(all_y_values) if all_y_values else 1.0
442
+ max_y_value = max(max_y_value, 1.0)
443
+
444
+ # Find a nice maximum value
445
+ magnitude = 10 ** math.floor(math.log10(max_y_value))
446
+ scaled = max_y_value / magnitude
447
+
448
+ if scaled <= 1:
449
+ nice_max = 1
450
+ elif scaled <= 2:
451
+ nice_max = 2
452
+ elif scaled <= 5:
453
+ nice_max = 5
454
+ else:
455
+ nice_max = 10
456
+
457
+ nice_max_value = nice_max * magnitude
458
+
459
+ # Create ticks with appropriate intervals
460
+ num_ticks = 5 # Fewer ticks for cleaner look
461
+ y_ticks = [i * (nice_max_value / (num_ticks - 1)) for i in range(num_ticks)]
462
+ y_labels = [format_speed(val) for val in y_ticks]
463
+
464
+ # Apply ticks to the plot
465
+ plt.yticks(y_ticks, y_labels)
466
+
467
+ return str(plt.build())
468
+ else:
469
+ return "Collecting data..."
470
+
471
+
196
472
  class ResourceVisualizer:
197
473
  """Class for visualizing Sing-Box resource statistics."""
198
474
 
@@ -205,6 +481,11 @@ class ResourceVisualizer:
205
481
  self.traffic_data_history: deque[dict[str, int]] = deque(maxlen=10)
206
482
  self.memory_data_history: deque[dict[str, int]] = deque(maxlen=10)
207
483
 
484
+ # For traffic graph
485
+ self.traffic_graph = TrafficGraph(
486
+ max_points=60 # Store 1 minutes of data
487
+ )
488
+
208
489
  def create_traffic_table(self, traffic_data: dict[str, Any]) -> Table:
209
490
  """Create a table displaying traffic statistics with averages."""
210
491
  # Add current data to history
@@ -295,98 +576,10 @@ class ResourceVisualizer:
295
576
 
296
577
  return table
297
578
 
298
- @staticmethod
299
- def create_connections_table(connections_data: dict[str, Any]) -> Panel:
300
- """
301
- Create a panel displaying active connections.
302
-
303
- Args:
304
- connections_data: Connections data from the API
305
-
306
- Returns:
307
- Rich Panel containing the connections information
308
- """
309
- if not connections_data or "connections" not in connections_data:
310
- return Panel("No connections data available", title="Active Connections")
311
-
312
- connections = connections_data.get("connections", [])
313
-
314
- if not connections:
315
- return Panel("No active connections", title="Active Connections")
316
-
317
- # Create table to display connections
318
- table = Table(
319
- title=f"Active Connections ({len(connections)})",
320
- expand=True,
321
- pad_edge=False, # Reduce padding
322
- )
323
- table.add_column("Host", style="cyan", no_wrap=True, max_width=20)
324
- table.add_column("Rule", style="bright_green", no_wrap=True, max_width=15)
325
- table.add_column("Chain", style="yellow", no_wrap=True, max_width=20)
326
- table.add_column("Network", justify="center", style="green")
327
- table.add_column("↑", justify="right", style="bright_blue")
328
- table.add_column("↓", justify="right", style="magenta")
329
- table.add_column("Duration", justify="right", style="green")
330
-
331
- # Sort connections by time
332
- sorted_connections = sort_connections(connections)
333
-
334
- # Show top connections (limit to avoid overwhelming the display)
335
- max_display = 15
336
- for conn in sorted_connections[:max_display]:
337
- # Extract data
338
- metadata = conn.get("metadata", {})
339
- host = metadata.get("host", "") or metadata.get("destinationIP", "?")
340
-
341
- network = metadata.get("network", "?").upper()
342
- upload = format_bytes(conn.get("upload", 0))
343
- download = format_bytes(conn.get("download", 0))
344
-
345
- # Extract rule information
346
- # Format rule display - extract useful parts
347
- rule_display = format_rule(conn.get("rule", ""))
348
-
349
- # Calculate duration
350
- duration_str = format_duration(conn.get("start", ""))
351
-
352
- # Format chains (usually shows proxy names)
353
- chain_str = format_chain(conn.get("chains", []))
354
-
355
- table.add_row(
356
- host, rule_display, chain_str, network, upload, download, duration_str
357
- )
358
-
359
- # Add summary information
360
- total_upload = format_bytes(connections_data.get("uploadTotal", 0))
361
- total_download = format_bytes(connections_data.get("downloadTotal", 0))
362
- memory_usage = format_bytes(connections_data.get("memory", 0))
363
-
364
- summary = f"Total Upload: {total_upload} | Total Download: {total_download} | Memory: {memory_usage}"
365
- if len(connections) > max_display:
366
- summary += f" | Showing {max_display} of {len(connections)} connections"
367
-
368
- # Return as a panel
369
- return Panel(
370
- Group(table, Text(summary, justify="center")), title="Connection Details"
371
- )
372
-
373
579
  def create_resources_layout(
374
- self,
375
- traffic_data: dict[str, Any],
376
- memory_data: dict[str, Any],
377
- connections_data: dict[str, Any],
580
+ self, traffic_data: dict[str, Any], memory_data: dict[str, Any]
378
581
  ) -> Layout:
379
- """
380
- Create the main layout with all resource components.
381
-
382
- Args:
383
- traffic_data: Traffic statistics data
384
- memory_data: Memory usage data
385
- connections_data: Connections data
386
-
387
- Returns:
388
- Rich Layout object
389
- """
582
+ """Create the main layout with all resource components."""
390
583
  layout = Layout()
391
584
 
392
585
  # Create main sections
@@ -396,29 +589,35 @@ class ResourceVisualizer:
396
589
  Layout(name="footer", size=1),
397
590
  )
398
591
 
399
- # Split main section into upper and connections
592
+ # Split main section for tables and graph
400
593
  layout["main"].split(
401
- Layout(name="upper", ratio=1), Layout(name="connections", ratio=3)
594
+ Layout(name="stats", ratio=1), Layout(name="graph", ratio=3)
402
595
  )
403
596
 
404
- # Split upper section into left and right
405
- layout["upper"].split_row(Layout(name="left"), Layout(name="right"))
597
+ # Split stats section into left and right
598
+ layout["stats"].split_row(
599
+ Layout(name="traffic_stats"), Layout(name="memory_stats")
600
+ )
406
601
 
407
602
  # Add components
408
- layout["header"].update(
603
+ layout["header"].update(create_header_panel("Sing-Box Resource Monitor"))
604
+
605
+ # Add tables
606
+ layout["stats"]["traffic_stats"].update(self.create_traffic_table(traffic_data))
607
+ layout["stats"]["memory_stats"].update(self.create_memory_table(memory_data))
608
+
609
+ # Update the traffic graph with new data
610
+ self.traffic_graph.update_from_traffic_data(traffic_data)
611
+
612
+ # Add the traffic graph to the layout
613
+ layout["graph"].update(
409
614
  Panel(
410
- Text("Sing-Box Resource Monitor", justify="center"),
411
- style="bold white on bright_blue",
412
- box=box.SIMPLE,
615
+ self.traffic_graph,
616
+ title="Network Traffic Over Time",
617
+ border_style="cyan",
413
618
  )
414
619
  )
415
620
 
416
- layout["upper"]["left"].update(self.create_traffic_table(traffic_data))
417
- layout["upper"]["right"].update(self.create_memory_table(memory_data))
418
-
419
- # Add connections panel
420
- layout["connections"].update(self.create_connections_table(connections_data))
421
-
422
621
  # Add footer
423
622
  layout["footer"].update(Text("Press Ctrl+C to exit", justify="center"))
424
623
 
@@ -482,11 +681,10 @@ class ResourceMonitor:
482
681
  # Use the current data from the streams
483
682
  traffic_data = self.current_traffic
484
683
  memory_data = self.current_memory
485
- connections_data = await self.api_client.get_connections()
486
684
 
487
685
  # Update display
488
686
  layout = self.visualizer.create_resources_layout(
489
- traffic_data, memory_data, connections_data
687
+ traffic_data, memory_data
490
688
  )
491
689
  live.update(layout)
492
690
 
@@ -0,0 +1,64 @@
1
+ from collections.abc import Generator
2
+ from io import StringIO
3
+ from typing import Any
4
+
5
+ from prompt_toolkit import ANSI
6
+ from prompt_toolkit.formatted_text import FormattedText, to_formatted_text
7
+ from rich import box
8
+ from rich.ansi import AnsiDecoder
9
+ from rich.console import Console, Group
10
+ from rich.jupyter import JupyterMixin
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+
15
+ def create_header_panel(title: str) -> Panel:
16
+ return Panel(
17
+ Text(title, justify="center"), style="bold white on bright_blue", box=box.SIMPLE
18
+ )
19
+
20
+
21
+ def render_rich_to_prompt_toolkit(rich_obj: Any) -> FormattedText:
22
+ """Convert a Rich object to prompt_toolkit formatted text."""
23
+ # Capture rich output as string
24
+ console = Console(
25
+ file=StringIO(), color_system="truecolor", highlight=True, force_terminal=True
26
+ )
27
+ console.print(rich_obj)
28
+ output = console.file.getvalue() # type: ignore
29
+
30
+ # Convert to prompt_toolkit formatted text
31
+ return to_formatted_text(ANSI(output))
32
+
33
+
34
+ class RichPlotMixin(JupyterMixin):
35
+ """A Rich-compatible plot using plotext."""
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__()
39
+ # For Rich rendering
40
+ self.decoder = AnsiDecoder()
41
+
42
+ def make_plot(self, width: int, height: int) -> str:
43
+ raise NotImplementedError
44
+
45
+ def __rich_console__(self, console, options) -> Generator[Group | str, Any, None]: # type: ignore[no-untyped-def]
46
+ """
47
+ Render the graph as a Rich console renderable.
48
+ Ref: https://github.com/piccolomo/plotext/blob/master/readme/environments.md#rich
49
+ Args:
50
+ console: The Rich console
51
+ options: Console options including width and height
52
+
53
+ Yields:
54
+ Rich-compatible renderables
55
+ """
56
+ # Adapt to the available width and height
57
+ width = options.max_width or console.width
58
+ height = options.height or console.height
59
+ # Build the plot and convert to Rich format
60
+ try:
61
+ canvas = self.make_plot(width, height)
62
+ yield Group(*self.decoder.decode(canvas))
63
+ except Exception as e:
64
+ yield f"Error rendering graph: {str(e)}"
@@ -2,8 +2,10 @@ version = 1
2
2
  revision = 1
3
3
  requires-python = ">=3.10"
4
4
  resolution-markers = [
5
- "sys_platform == 'win32'",
6
- "sys_platform == 'linux'",
5
+ "python_full_version >= '3.12' and sys_platform == 'win32'",
6
+ "python_full_version < '3.12' and sys_platform == 'win32'",
7
+ "python_full_version >= '3.12' and sys_platform == 'linux'",
8
+ "python_full_version < '3.12' and sys_platform == 'linux'",
7
9
  ]
8
10
  supported-markers = [
9
11
  "sys_platform == 'win32'",
@@ -277,6 +279,18 @@ wheels = [
277
279
  { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
278
280
  ]
279
281
 
282
+ [[package]]
283
+ name = "optype"
284
+ version = "0.9.2"
285
+ source = { registry = "https://pypi.org/simple" }
286
+ dependencies = [
287
+ { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
288
+ ]
289
+ sdist = { url = "https://files.pythonhosted.org/packages/32/c2/76a520809f1a5dd970eab9cc7da3aaa0e16e3fb8a0252fecec1a7a23ad73/optype-0.9.2.tar.gz", hash = "sha256:d7487a5b7fe96bacfc5dc8fab6b90c14f1e65e7ba6e7b22a8bef48d29690edac", size = 96126 }
290
+ wheels = [
291
+ { url = "https://files.pythonhosted.org/packages/ed/1a/40593f6fb9432fea750c9833e0cff0cd71452b9711b9df83faa251b0fe4e/optype-0.9.2-py3-none-any.whl", hash = "sha256:f9aee29e24794a7af637af80347b9fefbb110dffe012c133079615e551e52ef9", size = 84314 },
292
+ ]
293
+
280
294
  [[package]]
281
295
  name = "packaging"
282
296
  version = "24.2"
@@ -295,6 +309,15 @@ wheels = [
295
309
  { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
296
310
  ]
297
311
 
312
+ [[package]]
313
+ name = "plotext"
314
+ version = "5.3.2"
315
+ source = { registry = "https://pypi.org/simple" }
316
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967 }
317
+ wheels = [
318
+ { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047 },
319
+ ]
320
+
298
321
  [[package]]
299
322
  name = "pluggy"
300
323
  version = "1.5.0"
@@ -571,6 +594,18 @@ wheels = [
571
594
  { url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 },
572
595
  ]
573
596
 
597
+ [[package]]
598
+ name = "scipy-stubs"
599
+ version = "1.15.2.1"
600
+ source = { registry = "https://pypi.org/simple" }
601
+ dependencies = [
602
+ { name = "optype", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
603
+ ]
604
+ sdist = { url = "https://files.pythonhosted.org/packages/55/51/3d6dd7233d8fe19063cf0f4280fabbf5a0b0eaf270438808ca96b57eb118/scipy_stubs-1.15.2.1.tar.gz", hash = "sha256:ba9590ef3cd24511dced121ac0a9c8d9d0681ca5ac091128f0f7ba5a936a8693", size = 274081 }
605
+ wheels = [
606
+ { url = "https://files.pythonhosted.org/packages/17/9a/1019c6177105d8a26f9b29ad17b280c4d9495eedcd78b819ef8cee6a7c28/scipy_stubs-1.15.2.1-py3-none-any.whl", hash = "sha256:10977d731ec9b3fbff95dd5868903c9cd1caf8621f4ff8b3b45ef30148337bde", size = 458329 },
607
+ ]
608
+
574
609
  [[package]]
575
610
  name = "shellingham"
576
611
  version = "1.5.4"
@@ -585,6 +620,7 @@ name = "sing-box-cli"
585
620
  source = { editable = "." }
586
621
  dependencies = [
587
622
  { name = "httpx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
623
+ { name = "plotext", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
588
624
  { name = "prompt-toolkit", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
589
625
  { name = "psutil", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
590
626
  { name = "typer", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -598,6 +634,7 @@ dev = [
598
634
  { name = "pre-commit", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
599
635
  { name = "pytest", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
600
636
  { name = "ruff", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
637
+ { name = "scipy-stubs", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
601
638
  { name = "types-psutil", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
602
639
  { name = "typos", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
603
640
  ]
@@ -605,6 +642,7 @@ dev = [
605
642
  [package.metadata]
606
643
  requires-dist = [
607
644
  { name = "httpx", specifier = ">=0.28.1" },
645
+ { name = "plotext", specifier = ">=5.3.2" },
608
646
  { name = "prompt-toolkit", specifier = ">=3.0.50" },
609
647
  { name = "psutil", specifier = ">=6.1.1" },
610
648
  { name = "typer", specifier = ">=0.15.1" },
@@ -618,6 +656,7 @@ dev = [
618
656
  { name = "pre-commit", specifier = ">=3.8.0" },
619
657
  { name = "pytest", specifier = ">=8.3.5" },
620
658
  { name = "ruff", specifier = ">=0.6.3" },
659
+ { name = "scipy-stubs", specifier = ">=1.15.2.1" },
621
660
  { name = "types-psutil", specifier = ">=6.1.0.20241221" },
622
661
  { name = "typos", specifier = ">=1.26.8" },
623
662
  ]
Binary file
@@ -1,28 +0,0 @@
1
- from io import StringIO
2
- from typing import Any
3
-
4
- from prompt_toolkit import ANSI
5
- from prompt_toolkit.formatted_text import FormattedText, to_formatted_text
6
- from rich import box
7
- from rich.console import Console
8
- from rich.panel import Panel
9
- from rich.text import Text
10
-
11
-
12
- def create_header_panel(title: str) -> Panel:
13
- return Panel(
14
- Text(title, justify="center"), style="bold white on bright_blue", box=box.SIMPLE
15
- )
16
-
17
-
18
- def render_rich_to_prompt_toolkit(rich_obj: Any) -> FormattedText:
19
- """Convert a Rich object to prompt_toolkit formatted text."""
20
- # Capture rich output as string
21
- console = Console(
22
- file=StringIO(), color_system="truecolor", highlight=True, force_terminal=True
23
- )
24
- console.print(rich_obj)
25
- output = console.file.getvalue() # type: ignore
26
-
27
- # Convert to prompt_toolkit formatted text
28
- return to_formatted_text(ANSI(output))
File without changes
File without changes
File without changes