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.
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/CHANGELOG.md +16 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/PKG-INFO +2 -1
- sing_box_cli-0.0.24/assets/image.png +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/pyproject.toml +3 -1
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/__init__.py +1 -1
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/monitor.py +307 -109
- sing_box_cli-0.0.24/src/sing_box_service/widget.py +64 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/uv.lock +41 -2
- sing_box_cli-0.0.22/assets/image.png +0 -0
- sing_box_cli-0.0.22/src/sing_box_service/widget.py +0 -28
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.github/dependabot.yml +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.github/workflows/main.yml +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.gitignore +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/.pre-commit-config.yaml +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/LICENSE +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/README.md +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/bump.sh +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/format.sh +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/lint.sh +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/scripts/pre-commit.sh +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/client.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/config.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/connections.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/main.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/policy.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/py.typed +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/run.py +0 -0
- {sing_box_cli-0.0.22 → sing_box_cli-0.0.24}/src/sing_box_service/service.py +0 -0
- {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.
|
|
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.
|
|
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}"
|
|
@@ -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
|
-
|
|
9
|
-
from rich.console import Console
|
|
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
|
|
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
|
|
592
|
+
# Split main section for tables and graph
|
|
400
593
|
layout["main"].split(
|
|
401
|
-
Layout(name="
|
|
594
|
+
Layout(name="stats", ratio=1), Layout(name="graph", ratio=3)
|
|
402
595
|
)
|
|
403
596
|
|
|
404
|
-
# Split
|
|
405
|
-
layout["
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
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 == '
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|