sing-box-cli 0.0.26__tar.gz → 0.0.28__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.26 → sing_box_cli-0.0.28}/CHANGELOG.md +25 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/PKG-INFO +1 -1
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/pyproject.toml +1 -1
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/src/sing_box_service/__init__.py +1 -1
- sing_box_cli-0.0.28/src/sing_box_service/api/__init__.py +99 -0
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/client.py +47 -27
- sing_box_cli-0.0.28/src/sing_box_service/api/logs.py +93 -0
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/monitor.py +1 -1
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/policy.py +14 -51
- sing_box_cli-0.0.28/src/sing_box_service/common.py +26 -0
- sing_box_cli-0.0.28/src/sing_box_service/config/__init__.py +46 -0
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/config}/config.py +24 -57
- sing_box_cli-0.0.28/src/sing_box_service/config/utils.py +51 -0
- sing_box_cli-0.0.28/src/sing_box_service/main.py +74 -0
- sing_box_cli-0.0.28/src/sing_box_service/service/__init__.py +71 -0
- sing_box_cli-0.0.26/src/sing_box_service/service.py → sing_box_cli-0.0.28/src/sing_box_service/service/manager.py +20 -41
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/tests/test_config.py +1 -1
- sing_box_cli-0.0.26/src/sing_box_service/main.py +0 -262
- sing_box_cli-0.0.26/src/sing_box_service/run.py +0 -24
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/.github/dependabot.yml +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/.github/workflows/main.yml +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/.gitignore +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/.pre-commit-config.yaml +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/LICENSE +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/README.md +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/assets/image.png +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/scripts/bump.sh +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/scripts/format.sh +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/scripts/lint.sh +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/scripts/pre-commit.sh +0 -0
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/connections.py +0 -0
- {sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/widget.py +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/src/sing_box_service/py.typed +0 -0
- {sing_box_cli-0.0.26 → sing_box_cli-0.0.28}/uv.lock +0 -0
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.0.28 - 2025-03-21
|
|
6
|
+
|
|
7
|
+
### ⚙️ Miscellaneous Tasks
|
|
8
|
+
|
|
9
|
+
- Remove commands about capture logs in windows
|
|
10
|
+
|
|
11
|
+
## 0.0.27 - 2025-03-21
|
|
12
|
+
|
|
13
|
+
### 🐛 Bug Fixes
|
|
14
|
+
|
|
15
|
+
- Catch `asyncio.exceptions.CancelledError` while canceling stats display
|
|
16
|
+
- Logic of load groups, when to update delay and load delay history
|
|
17
|
+
|
|
18
|
+
### 🚜 Refactor
|
|
19
|
+
|
|
20
|
+
- Replace SingBoxCli class with typer context and modularize commands files
|
|
21
|
+
- Make_stream_request
|
|
22
|
+
- Display logs from api_client
|
|
23
|
+
- Simplify config_dir
|
|
24
|
+
|
|
25
|
+
### ⚙️ Miscellaneous Tasks
|
|
26
|
+
|
|
27
|
+
- Remove EmptyResponse
|
|
28
|
+
- Simplify api commands in top level
|
|
29
|
+
|
|
5
30
|
## 0.0.26 - 2025-03-20
|
|
6
31
|
|
|
7
32
|
### 🚜 Refactor
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.28
|
|
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
|
|
@@ -213,7 +213,7 @@ sort_commits = "oldest"
|
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
[tool.bumpversion]
|
|
216
|
-
current_version = "0.0.
|
|
216
|
+
current_version = "0.0.28"
|
|
217
217
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
218
218
|
serialize = ["{major}.{minor}.{patch}"]
|
|
219
219
|
search = "{current_version}"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..common import LogLevel, StrOrNone
|
|
8
|
+
from ..config import SingBoxConfig
|
|
9
|
+
from .client import SingBoxAPIClient
|
|
10
|
+
from .connections import ConnectionsManager
|
|
11
|
+
from .logs import get_logs
|
|
12
|
+
from .monitor import ResourceMonitor, ResourceVisualizer
|
|
13
|
+
from .policy import PolicyGroupManager
|
|
14
|
+
|
|
15
|
+
ApiUrlOption = Annotated[
|
|
16
|
+
StrOrNone,
|
|
17
|
+
typer.Option(
|
|
18
|
+
"--base-url",
|
|
19
|
+
"-u",
|
|
20
|
+
help="Base URL of the sing-box API, read from configuration file if not provided",
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
ApiTokenOption = Annotated[
|
|
24
|
+
StrOrNone,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--token",
|
|
27
|
+
"-t",
|
|
28
|
+
help="Authentication token for the sing-box API, read from configuration file if not provided",
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
LogLevelOption = Annotated[
|
|
33
|
+
LogLevel,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"--log-level",
|
|
36
|
+
"-l",
|
|
37
|
+
help="Log level of trace, debug, info, warning, error, fatal, panic",
|
|
38
|
+
case_sensitive=False,
|
|
39
|
+
),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
api = typer.Typer(help="sing-box manager.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@lru_cache
|
|
46
|
+
def create_client(
|
|
47
|
+
config: SingBoxConfig, base_url: StrOrNone = None, token: StrOrNone = None
|
|
48
|
+
) -> SingBoxAPIClient:
|
|
49
|
+
# read from config if not provided
|
|
50
|
+
if base_url is None:
|
|
51
|
+
base_url = config.api_base_url
|
|
52
|
+
if token is None:
|
|
53
|
+
token = config.api_secret
|
|
54
|
+
return SingBoxAPIClient(base_url, token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@api.command()
|
|
58
|
+
def stats(
|
|
59
|
+
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Show sing-box traffic, memory statistics and connections, requires API token(Optional)"""
|
|
62
|
+
|
|
63
|
+
api_client = create_client(ctx.obj.config, base_url, token)
|
|
64
|
+
visualizer = ResourceVisualizer()
|
|
65
|
+
monitor = ResourceMonitor(api_client, visualizer)
|
|
66
|
+
asyncio.run(monitor.start())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@api.command()
|
|
70
|
+
def conns(
|
|
71
|
+
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Manage sing-box connections, requires API token(Optional)"""
|
|
74
|
+
api_client = create_client(ctx.obj.config, base_url, token)
|
|
75
|
+
manager = ConnectionsManager(api_client)
|
|
76
|
+
asyncio.run(manager.run())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@api.command()
|
|
80
|
+
def proxy(
|
|
81
|
+
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Manage sing-box policy groups, requires API token(Optional)"""
|
|
84
|
+
api_client = create_client(ctx.obj.config, base_url, token)
|
|
85
|
+
manager = PolicyGroupManager(api_client)
|
|
86
|
+
asyncio.run(manager.run())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@api.command()
|
|
90
|
+
def logs(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
log_level: LogLevelOption = LogLevel.info,
|
|
93
|
+
base_url: ApiUrlOption = None,
|
|
94
|
+
token: ApiTokenOption = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Show sing-box logs, requires API token(Optional)"""
|
|
97
|
+
api_client = create_client(ctx.obj.config, base_url, token)
|
|
98
|
+
print("⌛ Showing real-time logs (Press Ctrl+C to exit)")
|
|
99
|
+
asyncio.run(get_logs(api_client, log_level))
|
{sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/client.py
RENAMED
|
@@ -75,7 +75,7 @@ class GroupInfo(FrozenBaseModel):
|
|
|
75
75
|
name: str
|
|
76
76
|
udp: bool = Field(description="UDP support")
|
|
77
77
|
history: list[DelayHistory] = Field(
|
|
78
|
-
description="History of selected proxies", default=[]
|
|
78
|
+
description="History delay of selected proxies", default=[]
|
|
79
79
|
)
|
|
80
80
|
now: str = Field(description="Currently selected proxy")
|
|
81
81
|
all: list[str] = Field(description="List of all outbound proxies")
|
|
@@ -90,13 +90,13 @@ class GroupsData(FrozenBaseModel):
|
|
|
90
90
|
class DelayTestResult(FrozenBaseModel):
|
|
91
91
|
"""Delay test result model."""
|
|
92
92
|
|
|
93
|
+
outbound: str = Field(default="", description="Outbound proxy name")
|
|
93
94
|
delay: int = Field(default=0, description="Outbound delay in milliseconds")
|
|
94
95
|
|
|
95
96
|
|
|
96
|
-
class
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
status_code: int
|
|
97
|
+
class LogEntry(FrozenBaseModel):
|
|
98
|
+
type: str = Field(description="Logs level")
|
|
99
|
+
payload: str = Field(description="Log message")
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
# Generic response type for API methods
|
|
@@ -187,29 +187,46 @@ class SingBoxAPIClient:
|
|
|
187
187
|
raw = await self._make_request_raw(method, endpoint, params, data)
|
|
188
188
|
return model_class.model_validate(raw)
|
|
189
189
|
|
|
190
|
-
async def
|
|
190
|
+
async def _make_stream_request(
|
|
191
|
+
self, endpoint: str, model_class: type[T], params: dict[str, str] | None = None
|
|
192
|
+
) -> AsyncGenerator[T, None]:
|
|
191
193
|
"""
|
|
192
|
-
|
|
194
|
+
Make a streaming request to the API.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
endpoint: API endpoint
|
|
198
|
+
model_class: Pydantic model class to validate response data
|
|
199
|
+
params: Optional query parameters for the request
|
|
193
200
|
|
|
194
201
|
Yields:
|
|
195
|
-
|
|
202
|
+
Validated model instances from the stream
|
|
196
203
|
"""
|
|
197
|
-
url = f"{self.base_url}
|
|
204
|
+
url = f"{self.base_url}{endpoint}"
|
|
198
205
|
|
|
199
206
|
async with httpx.AsyncClient() as client:
|
|
200
207
|
async with client.stream(
|
|
201
|
-
"GET", url, headers=self.headers, timeout=None
|
|
208
|
+
"GET", url, headers=self.headers, params=params, timeout=None
|
|
202
209
|
) as response:
|
|
203
210
|
response.raise_for_status()
|
|
204
211
|
async for line in response.aiter_lines():
|
|
205
212
|
if line.strip(): # Skip empty lines
|
|
206
213
|
try:
|
|
207
214
|
data = json.loads(line)
|
|
208
|
-
yield
|
|
215
|
+
yield model_class.model_validate(data)
|
|
209
216
|
except json.JSONDecodeError:
|
|
210
217
|
# Skip invalid JSON lines
|
|
211
218
|
continue
|
|
212
219
|
|
|
220
|
+
async def traffic_stream(self) -> AsyncGenerator[TrafficData, None]:
|
|
221
|
+
"""
|
|
222
|
+
Get traffic statistics as a stream of updates.
|
|
223
|
+
|
|
224
|
+
Yields:
|
|
225
|
+
TrafficData object containing traffic data (up/down in B/s)
|
|
226
|
+
"""
|
|
227
|
+
async for data in self._make_stream_request("/traffic", TrafficData):
|
|
228
|
+
yield data
|
|
229
|
+
|
|
213
230
|
async def memory_stream(self) -> AsyncGenerator[MemoryData, None]:
|
|
214
231
|
"""
|
|
215
232
|
Get memory statistics as a stream of updates.
|
|
@@ -217,21 +234,21 @@ class SingBoxAPIClient:
|
|
|
217
234
|
Yields:
|
|
218
235
|
MemoryData object containing memory data (inuse/total in bytes)
|
|
219
236
|
"""
|
|
220
|
-
|
|
237
|
+
async for data in self._make_stream_request("/memory", MemoryData):
|
|
238
|
+
yield data
|
|
221
239
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
continue
|
|
240
|
+
async def log_stream(
|
|
241
|
+
self, level: str | None = None
|
|
242
|
+
) -> AsyncGenerator[LogEntry, None]:
|
|
243
|
+
"""
|
|
244
|
+
Get log entries as a stream of updates.
|
|
245
|
+
|
|
246
|
+
Yields:
|
|
247
|
+
LogEntry object containing log data (type/payload)
|
|
248
|
+
"""
|
|
249
|
+
params = {"level": level} if level else None
|
|
250
|
+
async for data in self._make_stream_request("/logs", LogEntry, params=params):
|
|
251
|
+
yield data
|
|
235
252
|
|
|
236
253
|
async def get_connections(self) -> ConnectionData:
|
|
237
254
|
"""
|
|
@@ -293,7 +310,7 @@ class SingBoxAPIClient:
|
|
|
293
310
|
group_name: str,
|
|
294
311
|
url: str = "https://cp.cloudflare.com/generate_204",
|
|
295
312
|
timeout: int = 5000,
|
|
296
|
-
) ->
|
|
313
|
+
) -> list[DelayTestResult]:
|
|
297
314
|
"""
|
|
298
315
|
Test delay for all proxies in a policy group.
|
|
299
316
|
|
|
@@ -302,9 +319,12 @@ class SingBoxAPIClient:
|
|
|
302
319
|
timeout: Timeout in milliseconds
|
|
303
320
|
"""
|
|
304
321
|
params = {"url": url, "timeout": timeout}
|
|
305
|
-
|
|
322
|
+
reps = await self._make_request_raw(
|
|
306
323
|
"GET", f"/group/{group_name}/delay", params=params
|
|
307
324
|
)
|
|
325
|
+
return [
|
|
326
|
+
DelayTestResult(outbound=key, delay=value) for key, value in reps.items()
|
|
327
|
+
]
|
|
308
328
|
|
|
309
329
|
async def test_proxy_delay(
|
|
310
330
|
self,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from rich import print
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from ..common import LogLevel
|
|
9
|
+
from .client import SingBoxAPIClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_logs(client: SingBoxAPIClient, log_level: LogLevel) -> None:
|
|
13
|
+
# Define color mapping for log levels with brighter colors
|
|
14
|
+
# Ref: https://rich.readthedocs.io/en/stable/appendix/colors.html
|
|
15
|
+
log_level_colors = {
|
|
16
|
+
"trace": "bright_black",
|
|
17
|
+
"debug": "grey66",
|
|
18
|
+
"info": "bright_cyan",
|
|
19
|
+
"warning": "bright_yellow",
|
|
20
|
+
"error": "bright_red",
|
|
21
|
+
"fatal": "bright_magenta",
|
|
22
|
+
"panic": "bright_magenta",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Bright colors for IDs
|
|
26
|
+
id_colors = [
|
|
27
|
+
"bright_red",
|
|
28
|
+
"bright_green",
|
|
29
|
+
"bright_yellow",
|
|
30
|
+
"bright_blue",
|
|
31
|
+
"bright_magenta",
|
|
32
|
+
"bright_cyan",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Dictionary to store ID to color mapping
|
|
36
|
+
id_color_map = {}
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
async for entry in client.log_stream(log_level.value.lower()):
|
|
40
|
+
# Get current timestamp with automatic timezone offset
|
|
41
|
+
now = datetime.now().astimezone()
|
|
42
|
+
timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
|
|
43
|
+
|
|
44
|
+
# Get level color
|
|
45
|
+
# Start building the output message
|
|
46
|
+
message = Text()
|
|
47
|
+
message.append(timestamp, style="bright_black")
|
|
48
|
+
message.append(" ")
|
|
49
|
+
message.append(
|
|
50
|
+
entry.type.upper(),
|
|
51
|
+
style=log_level_colors.get(entry.type.lower(), "bright_white"),
|
|
52
|
+
)
|
|
53
|
+
message.append(" ")
|
|
54
|
+
|
|
55
|
+
# Process the payload
|
|
56
|
+
payload = entry.payload
|
|
57
|
+
|
|
58
|
+
# Find all ID patterns in the payload
|
|
59
|
+
pattern = r"\[(\d+) (\d+ms)\]"
|
|
60
|
+
last_end = 0
|
|
61
|
+
|
|
62
|
+
for match in re.finditer(pattern, payload):
|
|
63
|
+
# Add text before the match
|
|
64
|
+
message.append(payload[last_end : match.start()])
|
|
65
|
+
|
|
66
|
+
# Extract ID and time part
|
|
67
|
+
log_id = match.group(1)
|
|
68
|
+
time_part = match.group(2)
|
|
69
|
+
|
|
70
|
+
# Assign a consistent color to this ID
|
|
71
|
+
if log_id not in id_color_map:
|
|
72
|
+
color_index = hash(log_id) % len(id_colors)
|
|
73
|
+
id_color_map[log_id] = id_colors[color_index]
|
|
74
|
+
|
|
75
|
+
id_color = id_color_map[log_id]
|
|
76
|
+
|
|
77
|
+
# Add the ID with coloring
|
|
78
|
+
message.append("[", style="default")
|
|
79
|
+
message.append(log_id, style=id_color)
|
|
80
|
+
message.append(f" {time_part}]", style="default")
|
|
81
|
+
|
|
82
|
+
last_end = match.end()
|
|
83
|
+
|
|
84
|
+
# Add any remaining text
|
|
85
|
+
if last_end < len(payload):
|
|
86
|
+
message.append(payload[last_end:])
|
|
87
|
+
|
|
88
|
+
# Print the complete message
|
|
89
|
+
print(message)
|
|
90
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
91
|
+
pass
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"[red]Error:[/red] {e}")
|
{sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/monitor.py
RENAMED
|
@@ -691,7 +691,7 @@ class ResourceMonitor:
|
|
|
691
691
|
|
|
692
692
|
# Wait for next refresh
|
|
693
693
|
await asyncio.sleep(self.visualizer.refresh_rate - 0.1)
|
|
694
|
-
except KeyboardInterrupt:
|
|
694
|
+
except (asyncio.exceptions.CancelledError, KeyboardInterrupt):
|
|
695
695
|
self.running = False
|
|
696
696
|
break
|
|
697
697
|
except Exception as e:
|
{sing_box_cli-0.0.26/src/sing_box_service → sing_box_cli-0.0.28/src/sing_box_service/api}/policy.py
RENAMED
|
@@ -28,9 +28,7 @@ def create_policy_group_table(
|
|
|
28
28
|
current = group_data.now
|
|
29
29
|
proxies = group_data.all
|
|
30
30
|
# Get delay information, prioritizing the cache
|
|
31
|
-
# TODO: no sure if this is the best way to handle this
|
|
32
31
|
history = group_data.history
|
|
33
|
-
history_dict = {item.time: item.delay for item in history} if history else {}
|
|
34
32
|
|
|
35
33
|
# Create table for proxies
|
|
36
34
|
table = Table(expand=True, show_header=True)
|
|
@@ -48,8 +46,9 @@ def create_policy_group_table(
|
|
|
48
46
|
latency = f"{delay} ms"
|
|
49
47
|
else:
|
|
50
48
|
latency = "Timeout"
|
|
51
|
-
elif
|
|
52
|
-
delay
|
|
49
|
+
elif i == group_data.all.index(current):
|
|
50
|
+
# [0]->latest delay for the current proxy
|
|
51
|
+
delay = history[0].delay if history else -1
|
|
53
52
|
if delay > 0:
|
|
54
53
|
latency = f"{delay} ms"
|
|
55
54
|
else:
|
|
@@ -92,7 +91,6 @@ class PolicyGroupManager:
|
|
|
92
91
|
self.selected_proxy_index = 0
|
|
93
92
|
self.current_group: GroupInfo | None = None
|
|
94
93
|
self.status_message = "Loading policy groups..."
|
|
95
|
-
self.running = False
|
|
96
94
|
|
|
97
95
|
# Add a delay cache to store test results
|
|
98
96
|
# Format: {group_name: {proxy_name: delay_value}}
|
|
@@ -126,6 +124,9 @@ class PolicyGroupManager:
|
|
|
126
124
|
"""Switch between group list and proxy list."""
|
|
127
125
|
if self.focus_on_groups:
|
|
128
126
|
self.focus_on_groups = False
|
|
127
|
+
# initiate a test for the selected group
|
|
128
|
+
if self.current_group:
|
|
129
|
+
asyncio.create_task(self.test_group_delay(self.current_group.name))
|
|
129
130
|
else:
|
|
130
131
|
self.focus_on_groups = True
|
|
131
132
|
|
|
@@ -298,19 +299,7 @@ class PolicyGroupManager:
|
|
|
298
299
|
self.status_message = f"Loading group: {group_name}..."
|
|
299
300
|
|
|
300
301
|
data = await self.api_client.get_group(group_name)
|
|
301
|
-
|
|
302
|
-
# Make sure we have the history data
|
|
303
|
-
# Try to fetch delay data if not present
|
|
304
|
-
try:
|
|
305
|
-
await self.api_client.test_group_delay(group_name)
|
|
306
|
-
# Fetch the group data again to get updated delay information
|
|
307
|
-
data = await self.api_client.get_group(group_name)
|
|
308
|
-
except Exception:
|
|
309
|
-
# If testing fails, continue with existing data
|
|
310
|
-
pass
|
|
311
|
-
|
|
312
302
|
self.current_group = data
|
|
313
|
-
self.selected_proxy_index = 0
|
|
314
303
|
|
|
315
304
|
# Find the index of the currently selected proxy
|
|
316
305
|
current = data.now
|
|
@@ -372,16 +361,9 @@ class PolicyGroupManager:
|
|
|
372
361
|
if group_name not in self.delay_cache:
|
|
373
362
|
self.delay_cache[group_name] = {}
|
|
374
363
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
try:
|
|
379
|
-
result = await self.api_client.test_proxy_delay(proxy)
|
|
380
|
-
delay = result.delay
|
|
381
|
-
self.delay_cache[group_name][proxy] = delay
|
|
382
|
-
except Exception:
|
|
383
|
-
# If one proxy test fails, continue with others
|
|
384
|
-
pass
|
|
364
|
+
results = await self.api_client.test_group_delay(group_name)
|
|
365
|
+
for result in results:
|
|
366
|
+
self.delay_cache[group_name][result.outbound] = result.delay
|
|
385
367
|
|
|
386
368
|
self.status_message = f"Tested all proxies in group: {group_name}"
|
|
387
369
|
except Exception as e:
|
|
@@ -389,30 +371,11 @@ class PolicyGroupManager:
|
|
|
389
371
|
|
|
390
372
|
async def run(self) -> None:
|
|
391
373
|
"""Run the policy group manager."""
|
|
392
|
-
self.running = True
|
|
393
|
-
|
|
394
|
-
# Initial load of groups
|
|
395
|
-
await self.load_groups()
|
|
396
|
-
# Setup periodic refresh task
|
|
397
|
-
refresh_task = asyncio.create_task(self.periodic_refresh())
|
|
398
374
|
|
|
399
375
|
try:
|
|
376
|
+
# Initial load of groups
|
|
377
|
+
await self.load_groups()
|
|
400
378
|
await self.app.run_async()
|
|
401
|
-
|
|
402
|
-
self.
|
|
403
|
-
|
|
404
|
-
refresh_task.cancel()
|
|
405
|
-
try:
|
|
406
|
-
await refresh_task
|
|
407
|
-
except asyncio.CancelledError:
|
|
408
|
-
pass
|
|
409
|
-
|
|
410
|
-
async def periodic_refresh(self, interval: float = 5.0) -> None:
|
|
411
|
-
"""Periodically refresh the policy group data."""
|
|
412
|
-
while self.running:
|
|
413
|
-
try:
|
|
414
|
-
await asyncio.sleep(interval)
|
|
415
|
-
if self.current_group:
|
|
416
|
-
await self.load_selected_group()
|
|
417
|
-
except asyncio.CancelledError:
|
|
418
|
-
break
|
|
379
|
+
except Exception as e:
|
|
380
|
+
self.status_message = f"Error running application: {str(e)}"
|
|
381
|
+
await asyncio.sleep(5)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
StrOrNone = str | None
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogLevel(str, Enum):
|
|
8
|
+
trace = "trace"
|
|
9
|
+
debug = "debug"
|
|
10
|
+
info = "info"
|
|
11
|
+
warning = "warning"
|
|
12
|
+
error = "error"
|
|
13
|
+
fatal = "fatal"
|
|
14
|
+
panic = "panic"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_root() -> None:
|
|
18
|
+
"""https://gist.github.com/RDCH106/fdd419ef7dd803932b16056aab1d2300"""
|
|
19
|
+
try:
|
|
20
|
+
if os.geteuid() != 0: # type: ignore
|
|
21
|
+
raise SystemError("⚠️ This script must be run as root.")
|
|
22
|
+
except AttributeError:
|
|
23
|
+
import ctypes
|
|
24
|
+
|
|
25
|
+
if not ctypes.windll.shell32.IsUserAnAdmin(): # type: ignore
|
|
26
|
+
raise SystemError("⚠️ This script must be run as Administrator.")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ..common import ensure_root
|
|
6
|
+
from .config import SingBoxConfig, get_config
|
|
7
|
+
|
|
8
|
+
__all__ = ["config", "SingBoxConfig", "get_config"]
|
|
9
|
+
|
|
10
|
+
config = typer.Typer(help="Configuration management commands")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config.command("add-sub")
|
|
14
|
+
def config_add_sub(ctx: typer.Context, url: Annotated[str, typer.Argument()]) -> None:
|
|
15
|
+
"""Add subscription URL"""
|
|
16
|
+
ensure_root()
|
|
17
|
+
if ctx.obj.config.add_subscription(url):
|
|
18
|
+
# restart service if subscription is updated
|
|
19
|
+
if not ctx.obj.service.check_service():
|
|
20
|
+
ctx.obj.service.create_service()
|
|
21
|
+
print("⌛ Service created successfully.")
|
|
22
|
+
if ctx.obj.config.update_config():
|
|
23
|
+
ctx.obj.service.restart()
|
|
24
|
+
else:
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
else:
|
|
27
|
+
print("❌ Failed to add subscription.")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@config.command("show-sub")
|
|
32
|
+
def config_show_sub(ctx: typer.Context) -> None:
|
|
33
|
+
"""Show subscription URL"""
|
|
34
|
+
ctx.obj.config.show_subscription()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@config.command("show")
|
|
38
|
+
def config_show(ctx: typer.Context) -> None:
|
|
39
|
+
"""Show configuration file"""
|
|
40
|
+
ctx.obj.config.show_config()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@config.command("clean_cache")
|
|
44
|
+
def config_clean_cache(ctx: typer.Context) -> None:
|
|
45
|
+
"""Clean cache database"""
|
|
46
|
+
ctx.obj.config.clean_cache()
|