sing-box-cli 0.1.7__tar.gz → 0.1.9__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.1.7 → sing_box_cli-0.1.9}/PKG-INFO +64 -3
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/README.md +59 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/pyproject.toml +8 -3
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/__init__.py +1 -1
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/__init__.py +8 -1
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/client.py +11 -2
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/connections.py +9 -1
- sing_box_cli-0.1.9/src/sing_box_cli/api/logs.py +89 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/monitor.py +56 -22
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/__init__.py +12 -6
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/config.py +184 -85
- sing_box_cli-0.1.9/src/sing_box_cli/config/store.py +336 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/utils.py +46 -2
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/main.py +4 -1
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/service/__init__.py +24 -13
- sing_box_cli-0.1.9/src/sing_box_cli/service/desktop.py +336 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/service/manager.py +76 -14
- sing_box_cli-0.1.7/src/sing_box_cli/api/logs.py +0 -93
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/.gitignore +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/LICENSE +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/docs/config-serve.md +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/docs/traefik-config-serve.yml +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/hatch_build.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/policy.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/_serve_worker.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_backend.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/config/serve_tokens.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/installation.py +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/common.go +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/common_test.go +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/go.mod +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/go.sum +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/integration_windows_test.go +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/main_other.go +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/main_windows.go +0 -0
- {sing_box_cli-0.1.7 → sing_box_cli-0.1.9}/windows-service/testdata/core.go +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: cross-platform sing-box 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,12 +39,14 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
39
39
|
Classifier: Programming Language :: Python :: 3.14
|
|
40
40
|
Requires-Python: <3.15,>=3.11
|
|
41
41
|
Requires-Dist: httpx>=0.28.1
|
|
42
|
-
Requires-Dist: plotext
|
|
42
|
+
Requires-Dist: plotext<6,>=5.3.2
|
|
43
43
|
Requires-Dist: prompt-toolkit>=3.0.50
|
|
44
44
|
Requires-Dist: psutil>=6.1.1
|
|
45
45
|
Requires-Dist: pydantic>=2.10.6
|
|
46
|
-
Requires-Dist: sing-box-bin
|
|
46
|
+
Requires-Dist: sing-box-bin<1.14,>=1.12.12
|
|
47
47
|
Requires-Dist: typer>=0.15.1
|
|
48
|
+
Provides-Extra: desktop
|
|
49
|
+
Requires-Dist: grpcio>=1.60; extra == 'desktop'
|
|
48
50
|
Description-Content-Type: text/markdown
|
|
49
51
|
|
|
50
52
|
# Sing-Box CLI
|
|
@@ -167,6 +169,7 @@ Preview an update before applying it:
|
|
|
167
169
|
```bash
|
|
168
170
|
sbc config update https://sub.example.com/alice.json --dry-run
|
|
169
171
|
sbc config update https://sub.example.com/alice.json --restart
|
|
172
|
+
sbc config update --restart # no URL: the saved subscription
|
|
170
173
|
```
|
|
171
174
|
|
|
172
175
|
`--dry-run` downloads and validates the configuration, then shows its diff without
|
|
@@ -187,6 +190,64 @@ sbc config get --subscription # subscription URL
|
|
|
187
190
|
|
|
188
191
|
`config show` and `config show-sub` remain available as hidden compatibility aliases.
|
|
189
192
|
|
|
193
|
+
### Where it is kept
|
|
194
|
+
|
|
195
|
+
sbc keeps its configuration the way the official sing-box desktop app does, in one
|
|
196
|
+
place, whether the app is installed or not:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
<config dir>/settings.db profiles and the selected one
|
|
200
|
+
<config dir>/profiles/<id>.json the selected profile's sing-box configuration
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The config directory is `~/.config/sing-box` on Linux. The selected profile is the
|
|
204
|
+
configuration and its subscription URL is the subscription. A subscription token is
|
|
205
|
+
stored inside that URL (`?token=...`), as the app stores it. It is still sent as a
|
|
206
|
+
bearer token and never printed.
|
|
207
|
+
|
|
208
|
+
Without the app, sbc creates this store itself with the app's schema, so the app
|
|
209
|
+
takes it over unchanged if it is installed later. Files of older versions are
|
|
210
|
+
imported the first time sbc runs anything but `config get` or a `--dry-run`, which
|
|
211
|
+
create nothing:
|
|
212
|
+
|
|
213
|
+
- `config.json` becomes the profile. It stays as a link to the profile, so a
|
|
214
|
+
service created before keeps running the same file. The previous file is kept as
|
|
215
|
+
`config.json.migrated`.
|
|
216
|
+
- The URL and token of `app_config.json` become the subscription. The file is kept
|
|
217
|
+
as `app_config.json.migrated`.
|
|
218
|
+
- A service created before follows the profile: `service restart` and
|
|
219
|
+
`config update --restart` point it at the profile when it points elsewhere.
|
|
220
|
+
|
|
221
|
+
A configuration is written exactly as downloaded. The diff, and whether there is a
|
|
222
|
+
change at all, ignore formatting.
|
|
223
|
+
|
|
224
|
+
## sing-box desktop app (Linux)
|
|
225
|
+
|
|
226
|
+
When the official sing-box desktop app is installed, sbc drives its daemon instead
|
|
227
|
+
of a systemd service of its own, and the app's `settings.db` is the store. The app
|
|
228
|
+
counts as installed when its `sing-box-daemon.service` exists.
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
uv tool install 'sing-box-cli[desktop]' # grpcio is only needed for this
|
|
232
|
+
sbc service restart --update # sudo works too, it acts as the invoking user
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| Command | With the desktop app |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `service status/stop/restart/enable` | start, stop and reload the daemon's core. `restart` reloads in place, without a stop in between |
|
|
238
|
+
| `service disable` | stops the core. Autostart stays with the app's `sing-box-daemon.service` |
|
|
239
|
+
| `config update [URL]`, `--update` | refresh the profile selected in the app and record the refresh there. A new URL is saved to that profile once it downloads fine |
|
|
240
|
+
| `--clear-cache`, `config clear_cache` | clear the daemon's working directory, like "clear data" in the app |
|
|
241
|
+
| `stats/conns/proxy/logs` | unchanged, the default API address comes from the profile |
|
|
242
|
+
| `run` | refused, the daemon runs the core |
|
|
243
|
+
|
|
244
|
+
The core is started with the start options set in the app (OOM killer, memory
|
|
245
|
+
limit, power report). The app's store is never created or repaired from here: if it
|
|
246
|
+
cannot be read, or the daemon is stopped, sbc says so instead of working around it.
|
|
247
|
+
|
|
248
|
+
- A local profile of the app has no subscription, `config update URL` is refused for it.
|
|
249
|
+
- The app may show an old subscription URL until its profile list is reloaded.
|
|
250
|
+
|
|
190
251
|
## Serve private GitHub configurations
|
|
191
252
|
|
|
192
253
|
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
@@ -118,6 +118,7 @@ Preview an update before applying it:
|
|
|
118
118
|
```bash
|
|
119
119
|
sbc config update https://sub.example.com/alice.json --dry-run
|
|
120
120
|
sbc config update https://sub.example.com/alice.json --restart
|
|
121
|
+
sbc config update --restart # no URL: the saved subscription
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
`--dry-run` downloads and validates the configuration, then shows its diff without
|
|
@@ -138,6 +139,64 @@ sbc config get --subscription # subscription URL
|
|
|
138
139
|
|
|
139
140
|
`config show` and `config show-sub` remain available as hidden compatibility aliases.
|
|
140
141
|
|
|
142
|
+
### Where it is kept
|
|
143
|
+
|
|
144
|
+
sbc keeps its configuration the way the official sing-box desktop app does, in one
|
|
145
|
+
place, whether the app is installed or not:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
<config dir>/settings.db profiles and the selected one
|
|
149
|
+
<config dir>/profiles/<id>.json the selected profile's sing-box configuration
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The config directory is `~/.config/sing-box` on Linux. The selected profile is the
|
|
153
|
+
configuration and its subscription URL is the subscription. A subscription token is
|
|
154
|
+
stored inside that URL (`?token=...`), as the app stores it. It is still sent as a
|
|
155
|
+
bearer token and never printed.
|
|
156
|
+
|
|
157
|
+
Without the app, sbc creates this store itself with the app's schema, so the app
|
|
158
|
+
takes it over unchanged if it is installed later. Files of older versions are
|
|
159
|
+
imported the first time sbc runs anything but `config get` or a `--dry-run`, which
|
|
160
|
+
create nothing:
|
|
161
|
+
|
|
162
|
+
- `config.json` becomes the profile. It stays as a link to the profile, so a
|
|
163
|
+
service created before keeps running the same file. The previous file is kept as
|
|
164
|
+
`config.json.migrated`.
|
|
165
|
+
- The URL and token of `app_config.json` become the subscription. The file is kept
|
|
166
|
+
as `app_config.json.migrated`.
|
|
167
|
+
- A service created before follows the profile: `service restart` and
|
|
168
|
+
`config update --restart` point it at the profile when it points elsewhere.
|
|
169
|
+
|
|
170
|
+
A configuration is written exactly as downloaded. The diff, and whether there is a
|
|
171
|
+
change at all, ignore formatting.
|
|
172
|
+
|
|
173
|
+
## sing-box desktop app (Linux)
|
|
174
|
+
|
|
175
|
+
When the official sing-box desktop app is installed, sbc drives its daemon instead
|
|
176
|
+
of a systemd service of its own, and the app's `settings.db` is the store. The app
|
|
177
|
+
counts as installed when its `sing-box-daemon.service` exists.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv tool install 'sing-box-cli[desktop]' # grpcio is only needed for this
|
|
181
|
+
sbc service restart --update # sudo works too, it acts as the invoking user
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Command | With the desktop app |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `service status/stop/restart/enable` | start, stop and reload the daemon's core. `restart` reloads in place, without a stop in between |
|
|
187
|
+
| `service disable` | stops the core. Autostart stays with the app's `sing-box-daemon.service` |
|
|
188
|
+
| `config update [URL]`, `--update` | refresh the profile selected in the app and record the refresh there. A new URL is saved to that profile once it downloads fine |
|
|
189
|
+
| `--clear-cache`, `config clear_cache` | clear the daemon's working directory, like "clear data" in the app |
|
|
190
|
+
| `stats/conns/proxy/logs` | unchanged, the default API address comes from the profile |
|
|
191
|
+
| `run` | refused, the daemon runs the core |
|
|
192
|
+
|
|
193
|
+
The core is started with the start options set in the app (OOM killer, memory
|
|
194
|
+
limit, power report). The app's store is never created or repaired from here: if it
|
|
195
|
+
cannot be read, or the daemon is stopped, sbc says so instead of working around it.
|
|
196
|
+
|
|
197
|
+
- A local profile of the app has no subscription, `config update URL` is refused for it.
|
|
198
|
+
- The app may show an old subscription URL until its profile list is reloaded.
|
|
199
|
+
|
|
141
200
|
## Serve private GitHub configurations
|
|
142
201
|
|
|
143
202
|
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
@@ -23,11 +23,11 @@ maintainers = [{ name = "A.J.Zeller", email = "hello@atticux.me" }]
|
|
|
23
23
|
dynamic = ["version"] # https://hatch.pypa.io/latest/config/metadata/#version
|
|
24
24
|
dependencies = [
|
|
25
25
|
"httpx>=0.28.1",
|
|
26
|
-
"plotext>=5.3.2",
|
|
26
|
+
"plotext>=5.3.2,<6",
|
|
27
27
|
"prompt-toolkit>=3.0.50",
|
|
28
28
|
"psutil>=6.1.1",
|
|
29
29
|
"pydantic>=2.10.6",
|
|
30
|
-
"sing-box-bin>=1.12.12",
|
|
30
|
+
"sing-box-bin>=1.12.12,<1.14",
|
|
31
31
|
"typer>=0.15.1",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -47,6 +47,7 @@ environments = ["sys_platform == 'win32'", "sys_platform == 'linux'"]
|
|
|
47
47
|
|
|
48
48
|
[project.optional-dependencies]
|
|
49
49
|
# optional deps for package installation
|
|
50
|
+
desktop = ["grpcio>=1.60"] # drive the sing-box desktop app's daemon
|
|
50
51
|
|
|
51
52
|
[dependency-groups]
|
|
52
53
|
dev = [
|
|
@@ -61,6 +62,7 @@ dev = [
|
|
|
61
62
|
"scipy-stubs>=1.15.2.1",
|
|
62
63
|
"coverage>=7.7.1",
|
|
63
64
|
"pytest-sugar>=1.0.0",
|
|
65
|
+
"grpcio>=1.60", # fake desktop daemon in tests
|
|
64
66
|
]
|
|
65
67
|
|
|
66
68
|
[tool.hatch.version]
|
|
@@ -121,6 +123,9 @@ ignore = [
|
|
|
121
123
|
|
|
122
124
|
isort = { combine-as-imports = true, split-on-trailing-comma = false }
|
|
123
125
|
|
|
126
|
+
[tool.ruff.lint.per-file-ignores]
|
|
127
|
+
"tests/**" = ["ARG001"] # pytest fixtures are used by being requested
|
|
128
|
+
|
|
124
129
|
# Avoid trying to fix flake8-bugbear (`B`) violations.
|
|
125
130
|
unfixable = ["B"]
|
|
126
131
|
|
|
@@ -215,7 +220,7 @@ topo_order = false
|
|
|
215
220
|
sort_commits = "oldest"
|
|
216
221
|
|
|
217
222
|
[tool.bumpversion]
|
|
218
|
-
current_version = "0.1.
|
|
223
|
+
current_version = "0.1.9"
|
|
219
224
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
220
225
|
serialize = ["{major}.{minor}.{patch}"]
|
|
221
226
|
search = "{current_version}"
|
|
@@ -2,12 +2,13 @@ import asyncio
|
|
|
2
2
|
from collections.abc import Callable, Coroutine
|
|
3
3
|
from typing import Annotated, Any
|
|
4
4
|
|
|
5
|
+
import httpx
|
|
5
6
|
import typer
|
|
6
7
|
|
|
7
8
|
from ..common import LogLevel, StrOrNone
|
|
8
9
|
from ..config.config import ConfigHandler
|
|
9
10
|
from ..service import get_context_obj
|
|
10
|
-
from .client import SingBoxAPIClient
|
|
11
|
+
from .client import SingBoxAPIClient, SingBoxAPIError
|
|
11
12
|
from .connections import ConnectionsManager
|
|
12
13
|
from .logs import get_logs
|
|
13
14
|
from .monitor import ResourceMonitor, ResourceVisualizer
|
|
@@ -67,6 +68,12 @@ def _run_tui(
|
|
|
67
68
|
asyncio.run(command(api_client))
|
|
68
69
|
except (KeyboardInterrupt, EOFError):
|
|
69
70
|
pass
|
|
71
|
+
except SingBoxAPIError as e:
|
|
72
|
+
typer.echo(f"Error: {e}", err=True)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
except httpx.HTTPError as e:
|
|
75
|
+
typer.echo(f"Error: sing-box API request failed: {e}", err=True)
|
|
76
|
+
raise typer.Exit(1)
|
|
70
77
|
print("Exited.")
|
|
71
78
|
|
|
72
79
|
|
|
@@ -104,6 +104,10 @@ class LogEntry(FrozenBaseModel):
|
|
|
104
104
|
T = TypeVar("T", bound=FrozenBaseModel)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
class SingBoxAPIError(Exception):
|
|
108
|
+
"""The sing-box API is unreachable or rejected the client."""
|
|
109
|
+
|
|
110
|
+
|
|
107
111
|
class SingBoxAPIClient:
|
|
108
112
|
"""Async client for interacting with Sing-Box API.
|
|
109
113
|
|
|
@@ -123,7 +127,10 @@ class SingBoxAPIClient:
|
|
|
123
127
|
self.headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
124
128
|
self.timeout = timeout
|
|
125
129
|
if not self._health_check():
|
|
126
|
-
raise
|
|
130
|
+
raise SingBoxAPIError(
|
|
131
|
+
f"sing-box API is not reachable at {self.base_url}, "
|
|
132
|
+
"check that sing-box is running and the API URL and token are correct"
|
|
133
|
+
)
|
|
127
134
|
|
|
128
135
|
def _health_check(self) -> bool:
|
|
129
136
|
"""Check if the API is reachable.
|
|
@@ -204,9 +211,11 @@ class SingBoxAPIClient:
|
|
|
204
211
|
"""
|
|
205
212
|
url = f"{self.base_url}{endpoint}"
|
|
206
213
|
|
|
214
|
+
# streams may stay idle for long, so only reads are unbounded
|
|
215
|
+
timeout = httpx.Timeout(self.timeout, read=None)
|
|
207
216
|
async with httpx.AsyncClient() as client:
|
|
208
217
|
async with client.stream(
|
|
209
|
-
"GET", url, headers=self.headers, params=params, timeout=
|
|
218
|
+
"GET", url, headers=self.headers, params=params, timeout=timeout
|
|
210
219
|
) as response:
|
|
211
220
|
response.raise_for_status()
|
|
212
221
|
async for line in response.aiter_lines():
|
|
@@ -440,10 +440,18 @@ class ConnectionsManager:
|
|
|
440
440
|
pass
|
|
441
441
|
|
|
442
442
|
async def periodic_refresh(self, interval: float = 0.5) -> None:
|
|
443
|
-
"""Periodically refresh connections."""
|
|
443
|
+
"""Periodically refresh connections, retrying while the API is unreachable."""
|
|
444
|
+
failed = False
|
|
444
445
|
while self.running:
|
|
445
446
|
try:
|
|
446
447
|
await asyncio.sleep(interval)
|
|
447
448
|
await self.refresh_connections()
|
|
448
449
|
except asyncio.CancelledError:
|
|
449
450
|
break
|
|
451
|
+
except Exception:
|
|
452
|
+
# refresh_connections already shows the error in the status bar
|
|
453
|
+
failed = True
|
|
454
|
+
continue
|
|
455
|
+
if failed:
|
|
456
|
+
failed = False
|
|
457
|
+
self.status_message = self.get_filter_state()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from contextlib import aclosing
|
|
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
|
+
async with aclosing(client.log_stream(log_level.value.lower())) as stream:
|
|
39
|
+
async for entry in stream:
|
|
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)
|
|
@@ -2,6 +2,7 @@ import asyncio
|
|
|
2
2
|
import math
|
|
3
3
|
import statistics
|
|
4
4
|
from collections import deque
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable
|
|
5
6
|
from contextlib import aclosing
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from enum import Enum
|
|
@@ -14,7 +15,13 @@ from rich.panel import Panel
|
|
|
14
15
|
from rich.table import Table
|
|
15
16
|
from rich.text import Text
|
|
16
17
|
|
|
17
|
-
from .client import
|
|
18
|
+
from .client import (
|
|
19
|
+
ConnectionInfo,
|
|
20
|
+
FrozenBaseModel,
|
|
21
|
+
MemoryData,
|
|
22
|
+
SingBoxAPIClient,
|
|
23
|
+
TrafficData,
|
|
24
|
+
)
|
|
18
25
|
from .widget import RichPlotMixin, create_header_panel
|
|
19
26
|
|
|
20
27
|
|
|
@@ -575,7 +582,10 @@ class ResourceVisualizer:
|
|
|
575
582
|
return table
|
|
576
583
|
|
|
577
584
|
def create_resources_layout(
|
|
578
|
-
self,
|
|
585
|
+
self,
|
|
586
|
+
traffic_data: TrafficData,
|
|
587
|
+
memory_data: MemoryData,
|
|
588
|
+
api_error: str | None = None,
|
|
579
589
|
) -> Layout:
|
|
580
590
|
"""Create the main layout with all resource components."""
|
|
581
591
|
layout = Layout()
|
|
@@ -617,7 +627,17 @@ class ResourceVisualizer:
|
|
|
617
627
|
)
|
|
618
628
|
|
|
619
629
|
# Add footer
|
|
620
|
-
|
|
630
|
+
if api_error:
|
|
631
|
+
footer = Text(
|
|
632
|
+
f"{api_error} | Press Ctrl+C to exit",
|
|
633
|
+
style="bold red",
|
|
634
|
+
justify="center",
|
|
635
|
+
no_wrap=True,
|
|
636
|
+
overflow="ellipsis",
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
footer = Text("Press Ctrl+C to exit", justify="center")
|
|
640
|
+
layout["footer"].update(footer)
|
|
621
641
|
|
|
622
642
|
return layout
|
|
623
643
|
|
|
@@ -642,30 +662,44 @@ class ResourceMonitor:
|
|
|
642
662
|
self.current_memory = MemoryData(inuse=0, total=0) # in bytes
|
|
643
663
|
# 1.0 fix interval for avoiding repeated data
|
|
644
664
|
self.task_interval = RefreshRate.SLOW.value
|
|
665
|
+
self.reconnect_interval = RefreshRate.SLOW.value
|
|
666
|
+
self.api_error: str | None = None
|
|
645
667
|
|
|
646
668
|
async def monitor_traffic(self) -> None:
|
|
647
669
|
"""Monitor traffic stream from the API."""
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
self.current_traffic = traffic_data
|
|
652
|
-
if not self.running:
|
|
653
|
-
break
|
|
654
|
-
await asyncio.sleep(self.task_interval)
|
|
655
|
-
except Exception as e:
|
|
656
|
-
self.visualizer.console.print(f"[red]Error monitoring traffic: {str(e)}")
|
|
670
|
+
await self._follow_stream(
|
|
671
|
+
self.api_client.traffic_stream, "current_traffic", TrafficData()
|
|
672
|
+
)
|
|
657
673
|
|
|
658
674
|
async def monitor_memory(self) -> None:
|
|
659
675
|
"""Monitor memory stream from the API."""
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
676
|
+
await self._follow_stream(
|
|
677
|
+
self.api_client.memory_stream, "current_memory", MemoryData()
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
async def _follow_stream(
|
|
681
|
+
self,
|
|
682
|
+
open_stream: Callable[[], AsyncGenerator[FrozenBaseModel, None]],
|
|
683
|
+
attr: str,
|
|
684
|
+
empty: FrozenBaseModel,
|
|
685
|
+
) -> None:
|
|
686
|
+
"""Keep ``attr`` updated from a stream, reopening it whenever it ends."""
|
|
687
|
+
while self.running:
|
|
688
|
+
try:
|
|
689
|
+
async with aclosing(open_stream()) as stream:
|
|
690
|
+
async for data in stream:
|
|
691
|
+
setattr(self, attr, data)
|
|
692
|
+
self.api_error = None
|
|
693
|
+
if not self.running:
|
|
694
|
+
return
|
|
695
|
+
await asyncio.sleep(self.task_interval)
|
|
696
|
+
reason = "closed by sing-box"
|
|
697
|
+
except Exception as e:
|
|
698
|
+
reason = str(e) or type(e).__name__
|
|
699
|
+
# don't keep showing stale numbers while sing-box is down
|
|
700
|
+
setattr(self, attr, empty)
|
|
701
|
+
self.api_error = f"sing-box API stream lost ({reason}), reconnecting..."
|
|
702
|
+
await asyncio.sleep(self.reconnect_interval)
|
|
669
703
|
|
|
670
704
|
async def refresh_display(self) -> None:
|
|
671
705
|
"""Main display refresh loop."""
|
|
@@ -685,7 +719,7 @@ class ResourceMonitor:
|
|
|
685
719
|
|
|
686
720
|
# Update display
|
|
687
721
|
layout = self.visualizer.create_resources_layout(
|
|
688
|
-
traffic_data, memory_data
|
|
722
|
+
traffic_data, memory_data, self.api_error
|
|
689
723
|
)
|
|
690
724
|
live.update(layout)
|
|
691
725
|
|
|
@@ -8,10 +8,13 @@ from ..service import SharedContext, get_context_obj
|
|
|
8
8
|
from ..service.manager import create_service
|
|
9
9
|
from .config import get_config
|
|
10
10
|
from .serve import serve
|
|
11
|
+
from .utils import redact_url
|
|
11
12
|
|
|
12
13
|
__all__ = ["config"]
|
|
13
14
|
|
|
14
|
-
SubUrlArg = Annotated[
|
|
15
|
+
SubUrlArg = Annotated[
|
|
16
|
+
StrOrNone, typer.Argument(help="Subscription URL, the saved one if omitted")
|
|
17
|
+
]
|
|
15
18
|
TokenOption = Annotated[
|
|
16
19
|
StrOrNone,
|
|
17
20
|
typer.Option(
|
|
@@ -40,7 +43,7 @@ def config_callback(ctx: typer.Context) -> None:
|
|
|
40
43
|
|
|
41
44
|
@config.command("update")
|
|
42
45
|
def config_update(
|
|
43
|
-
url: SubUrlArg,
|
|
46
|
+
url: SubUrlArg = None,
|
|
44
47
|
token: TokenOption = None,
|
|
45
48
|
restart: RestartServiceOption = False,
|
|
46
49
|
dry_run: DryRunOption = False,
|
|
@@ -51,10 +54,13 @@ def config_update(
|
|
|
51
54
|
print("❌ Failed to update configuration.")
|
|
52
55
|
raise typer.Exit(1)
|
|
53
56
|
if restart and not dry_run:
|
|
54
|
-
ensure_root()
|
|
55
57
|
service = create_service(cfg)
|
|
58
|
+
if service.requires_root:
|
|
59
|
+
ensure_root()
|
|
56
60
|
# init service
|
|
57
|
-
if
|
|
61
|
+
if service.check_service():
|
|
62
|
+
service.sync_service()
|
|
63
|
+
else:
|
|
58
64
|
service.create_service()
|
|
59
65
|
print("⌛ Service created successfully.")
|
|
60
66
|
service.restart()
|
|
@@ -71,7 +77,7 @@ def config_get(
|
|
|
71
77
|
cfg = get_context_obj(ctx).config
|
|
72
78
|
if subscription:
|
|
73
79
|
if cfg.sub_url:
|
|
74
|
-
print(f"🔗 Current subscription URL: {cfg.sub_url}")
|
|
80
|
+
print(f"🔗 Current subscription URL: {redact_url(cfg.sub_url)}")
|
|
75
81
|
else:
|
|
76
82
|
print("❌ No subscription URL found.")
|
|
77
83
|
else:
|
|
@@ -94,4 +100,4 @@ def config_show(ctx: typer.Context) -> None:
|
|
|
94
100
|
def config_clear_cache(ctx: typer.Context) -> None:
|
|
95
101
|
"""Clean cache database"""
|
|
96
102
|
ctx_obj = get_context_obj(ctx)
|
|
97
|
-
ctx_obj.
|
|
103
|
+
ctx_obj.service.clear_cache()
|