sing-box-cli 0.1.2__tar.gz → 0.1.3__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.2 → sing_box_cli-0.1.3}/PKG-INFO +40 -3
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/README.md +39 -2
- sing_box_cli-0.1.3/docs/config-serve.md +49 -0
- sing_box_cli-0.1.3/docs/traefik-config-serve.yml +19 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/pyproject.toml +2 -2
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/__init__.py +1 -1
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/__init__.py +27 -17
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/client.py +14 -6
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/connections.py +22 -21
- sing_box_cli-0.1.3/src/sing_box_cli/api/logs.py +93 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/monitor.py +45 -56
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/policy.py +18 -16
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/__init__.py +12 -1
- sing_box_cli-0.1.3/src/sing_box_cli/config/_serve_worker.py +56 -0
- sing_box_cli-0.1.3/src/sing_box_cli/config/serve.py +282 -0
- sing_box_cli-0.1.3/src/sing_box_cli/config/serve_backend.py +340 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/utils.py +3 -1
- sing_box_cli-0.1.3/src/sing_box_cli/installation.py +67 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/main.py +15 -0
- sing_box_cli-0.1.2/src/sing_box_cli/api/logs.py +0 -93
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/.gitignore +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/LICENSE +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/hatch_build.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/config.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/service/__init__.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/service/manager.py +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/common.go +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/common_test.go +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/go.mod +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/go.sum +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/integration_windows_test.go +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/main_other.go +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/main_windows.go +0 -0
- {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/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.3
|
|
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
|
|
@@ -80,10 +80,19 @@ uv tool install sing-box-cli
|
|
|
80
80
|
```bash
|
|
81
81
|
# linux
|
|
82
82
|
uv tool install sing-box-cli
|
|
83
|
-
|
|
84
|
-
sudo ln -sf $(which sbc) /usr/local/bin/
|
|
83
|
+
sbc install
|
|
85
84
|
```
|
|
86
85
|
|
|
86
|
+
On Linux, `sbc install` installs both `sbc` and `sing-box-cli` into
|
|
87
|
+
`/usr/local/bin`, requesting sudo only to write those system launchers. It replaces
|
|
88
|
+
the old manual symlinks and can be run again safely. Use `--bin-dir PATH` to select
|
|
89
|
+
another existing executable directory. Unrelated files are preserved.
|
|
90
|
+
|
|
91
|
+
The system launchers use Python `-B`, so commands such as `sudo sbc service enable`
|
|
92
|
+
do not create root-owned `__pycache__` files in your uv environment. Run the install
|
|
93
|
+
command as your normal user after installing the tool. Do not run `uv tool install`,
|
|
94
|
+
`uv tool upgrade`, or `uv run` with sudo.
|
|
95
|
+
|
|
87
96
|
Install with specific sing-box version
|
|
88
97
|
|
|
89
98
|
```bash
|
|
@@ -95,6 +104,9 @@ Upgrade
|
|
|
95
104
|
uv tool upgrade sing-box-cli
|
|
96
105
|
```
|
|
97
106
|
|
|
107
|
+
Linux launchers continue to use the same environment after a normal uv upgrade.
|
|
108
|
+
Run `sbc install` again if you move or change the installation environment.
|
|
109
|
+
|
|
98
110
|
After upgrading on Windows, run the following in an administrator PowerShell:
|
|
99
111
|
|
|
100
112
|
```powershell
|
|
@@ -147,3 +159,28 @@ Linux
|
|
|
147
159
|
```bash
|
|
148
160
|
sudo sbc --help
|
|
149
161
|
```
|
|
162
|
+
|
|
163
|
+
## Serve private GitHub configurations
|
|
164
|
+
|
|
165
|
+
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
166
|
+
repository, then run as your normal user:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
sbc config serve add https://github.com/owner/private-configs --domain sub.example.com
|
|
170
|
+
sbc config serve list
|
|
171
|
+
sbc config serve start
|
|
172
|
+
sbc config serve stop
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`list` prints URLs for valid root `.json` configurations. The server reads GitHub
|
|
176
|
+
through its API without cloning; `start` runs in the background at `127.0.0.1:8080`
|
|
177
|
+
by default. Use `--host` and `--port` for another listening address.
|
|
178
|
+
|
|
179
|
+
Point Traefik at the server using [this example](docs/traefik-config-serve.yml).
|
|
180
|
+
Clients continue to use the existing update command:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
sbc config update https://sub.example.com/alice.json --restart
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [configuration server details](docs/config-serve.md) for settings, logs and limits.
|
|
@@ -31,10 +31,19 @@ uv tool install sing-box-cli
|
|
|
31
31
|
```bash
|
|
32
32
|
# linux
|
|
33
33
|
uv tool install sing-box-cli
|
|
34
|
-
|
|
35
|
-
sudo ln -sf $(which sbc) /usr/local/bin/
|
|
34
|
+
sbc install
|
|
36
35
|
```
|
|
37
36
|
|
|
37
|
+
On Linux, `sbc install` installs both `sbc` and `sing-box-cli` into
|
|
38
|
+
`/usr/local/bin`, requesting sudo only to write those system launchers. It replaces
|
|
39
|
+
the old manual symlinks and can be run again safely. Use `--bin-dir PATH` to select
|
|
40
|
+
another existing executable directory. Unrelated files are preserved.
|
|
41
|
+
|
|
42
|
+
The system launchers use Python `-B`, so commands such as `sudo sbc service enable`
|
|
43
|
+
do not create root-owned `__pycache__` files in your uv environment. Run the install
|
|
44
|
+
command as your normal user after installing the tool. Do not run `uv tool install`,
|
|
45
|
+
`uv tool upgrade`, or `uv run` with sudo.
|
|
46
|
+
|
|
38
47
|
Install with specific sing-box version
|
|
39
48
|
|
|
40
49
|
```bash
|
|
@@ -46,6 +55,9 @@ Upgrade
|
|
|
46
55
|
uv tool upgrade sing-box-cli
|
|
47
56
|
```
|
|
48
57
|
|
|
58
|
+
Linux launchers continue to use the same environment after a normal uv upgrade.
|
|
59
|
+
Run `sbc install` again if you move or change the installation environment.
|
|
60
|
+
|
|
49
61
|
After upgrading on Windows, run the following in an administrator PowerShell:
|
|
50
62
|
|
|
51
63
|
```powershell
|
|
@@ -98,3 +110,28 @@ Linux
|
|
|
98
110
|
```bash
|
|
99
111
|
sudo sbc --help
|
|
100
112
|
```
|
|
113
|
+
|
|
114
|
+
## Serve private GitHub configurations
|
|
115
|
+
|
|
116
|
+
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
117
|
+
repository, then run as your normal user:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
sbc config serve add https://github.com/owner/private-configs --domain sub.example.com
|
|
121
|
+
sbc config serve list
|
|
122
|
+
sbc config serve start
|
|
123
|
+
sbc config serve stop
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`list` prints URLs for valid root `.json` configurations. The server reads GitHub
|
|
127
|
+
through its API without cloning; `start` runs in the background at `127.0.0.1:8080`
|
|
128
|
+
by default. Use `--host` and `--port` for another listening address.
|
|
129
|
+
|
|
130
|
+
Point Traefik at the server using [this example](docs/traefik-config-serve.yml).
|
|
131
|
+
Clients continue to use the existing update command:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
sbc config update https://sub.example.com/alice.json --restart
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
See [configuration server details](docs/config-serve.md) for settings, logs and limits.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Configuration server
|
|
2
|
+
|
|
3
|
+
The server maps one domain to one private GitHub repository. Multiple domains can
|
|
4
|
+
share a server. Set `GH_TOKEN` or `GITHUB_TOKEN` to a fine-grained token with
|
|
5
|
+
**Contents: read** access to all registered repositories.
|
|
6
|
+
|
|
7
|
+
`config serve add` checks GitHub access and saves the binding; registering the same
|
|
8
|
+
binding again is safe. `config serve list` validates root `.json` files with the
|
|
9
|
+
sing-box core bundled with this package, prints valid URLs, and reports invalid
|
|
10
|
+
configurations on stderr.
|
|
11
|
+
|
|
12
|
+
## Background process
|
|
13
|
+
|
|
14
|
+
`config serve start` waits until the socket is listening, then prints the PID,
|
|
15
|
+
address and log path. Closing the terminal leaves the server running. Repeated
|
|
16
|
+
starts use the existing process, and repeated stops are safe. Run `stop` as the
|
|
17
|
+
same user with the same state directory. Restart after changing the GitHub token
|
|
18
|
+
or upgrading the package. Boot-time startup is managed separately by deployment.
|
|
19
|
+
|
|
20
|
+
Settings, process identity and `serve.log` are stored in the platform's
|
|
21
|
+
`sing-box-cli/serve` application directory (`~/.config/sing-box-cli/serve` on Linux).
|
|
22
|
+
Set `SBC_SERVE_DIR` to use another directory for all commands. Tokens and client
|
|
23
|
+
configuration are not stored there. New registrations take effect while running.
|
|
24
|
+
|
|
25
|
+
## GitHub reads and validation
|
|
26
|
+
|
|
27
|
+
Requests read the repository's default branch through the GitHub Contents API.
|
|
28
|
+
No Git installation, clone/pull, background synchronization or persistent config
|
|
29
|
+
cache is needed. File updates and deletions take effect on subsequent requests.
|
|
30
|
+
|
|
31
|
+
Each response is validated and preserves the original content. Missing files,
|
|
32
|
+
invalid configurations and GitHub errors return HTTP errors. Configurations are
|
|
33
|
+
limited to 5 MiB and must be compatible with the bundled core. Referenced sibling
|
|
34
|
+
files are not downloaded. Existing client `--token` subscriptions and saved
|
|
35
|
+
settings remain supported; server URLs need no client token.
|
|
36
|
+
|
|
37
|
+
## Traefik
|
|
38
|
+
|
|
39
|
+
Merge [the example router and service](traefik-config-serve.yml) into the existing
|
|
40
|
+
`http.routers` and `http.services` sections. Traefik must preserve the `Host` header.
|
|
41
|
+
The example uses `websecure` and the host gateway from `digitalocean-public`'s
|
|
42
|
+
`~/devspace/traefik.yml`. Bind to that gateway for this deployment:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
sbc config serve start --host 172.18.0.1 --port 8080
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Replace `sub.example.com` with your domain and configure DNS and the TLS
|
|
49
|
+
certificate through your deployment. `--domain` only registers the binding.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Merge these entries into the existing http.routers and http.services sections.
|
|
2
|
+
# Set the domain to match config serve add --domain.
|
|
3
|
+
http:
|
|
4
|
+
routers:
|
|
5
|
+
config-serve:
|
|
6
|
+
rule: "Host(`sub.example.com`)"
|
|
7
|
+
entryPoints:
|
|
8
|
+
- websecure
|
|
9
|
+
service: config-serve
|
|
10
|
+
tls: {}
|
|
11
|
+
|
|
12
|
+
services:
|
|
13
|
+
config-serve:
|
|
14
|
+
loadBalancer:
|
|
15
|
+
passHostHeader: true
|
|
16
|
+
servers:
|
|
17
|
+
# Host gateway used by the existing digitalocean-public Traefik config.
|
|
18
|
+
# Bind sbc config serve start --host 172.18.0.1 --port 8080 on the host.
|
|
19
|
+
- url: "http://172.18.0.1:8080"
|
|
@@ -75,7 +75,7 @@ packages = ["src/sing_box_cli"]
|
|
|
75
75
|
path = "hatch_build.py"
|
|
76
76
|
|
|
77
77
|
[tool.hatch.build.targets.sdist]
|
|
78
|
-
include = ["/src", "/windows-service", "/hatch_build.py", "/pyproject.toml", "/README.md", "/LICENSE"]
|
|
78
|
+
include = ["/src", "/windows-service", "/docs", "/hatch_build.py", "/pyproject.toml", "/README.md", "/LICENSE"]
|
|
79
79
|
|
|
80
80
|
## Test
|
|
81
81
|
[tool.mypy]
|
|
@@ -215,7 +215,7 @@ topo_order = false
|
|
|
215
215
|
sort_commits = "oldest"
|
|
216
216
|
|
|
217
217
|
[tool.bumpversion]
|
|
218
|
-
current_version = "0.1.
|
|
218
|
+
current_version = "0.1.3"
|
|
219
219
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
220
220
|
serialize = ["{major}.{minor}.{patch}"]
|
|
221
221
|
search = "{current_version}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from
|
|
2
|
+
from collections.abc import Callable, Coroutine
|
|
3
|
+
from typing import Annotated, Any
|
|
3
4
|
|
|
4
5
|
import typer
|
|
5
6
|
|
|
@@ -53,16 +54,33 @@ def create_client(
|
|
|
53
54
|
return SingBoxAPIClient(base_url, token)
|
|
54
55
|
|
|
55
56
|
|
|
57
|
+
def _run_tui(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
command: Callable[[SingBoxAPIClient], Coroutine[Any, Any, None]],
|
|
60
|
+
base_url: StrOrNone,
|
|
61
|
+
token: StrOrNone,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Run an interactive command and handle user-requested exits consistently."""
|
|
64
|
+
try:
|
|
65
|
+
ctx_obj = get_context_obj(ctx)
|
|
66
|
+
api_client = create_client(ctx_obj.config, base_url, token)
|
|
67
|
+
asyncio.run(command(api_client))
|
|
68
|
+
except (KeyboardInterrupt, EOFError):
|
|
69
|
+
pass
|
|
70
|
+
print("Exited.")
|
|
71
|
+
|
|
72
|
+
|
|
56
73
|
@api.command()
|
|
57
74
|
def stats(
|
|
58
75
|
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
59
76
|
) -> None:
|
|
60
77
|
"""Show sing-box traffic, memory statistics and connections, requires API token(Optional)"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
_run_tui(
|
|
79
|
+
ctx,
|
|
80
|
+
lambda client: ResourceMonitor(client, ResourceVisualizer()).start(),
|
|
81
|
+
base_url,
|
|
82
|
+
token,
|
|
83
|
+
)
|
|
66
84
|
|
|
67
85
|
|
|
68
86
|
@api.command()
|
|
@@ -70,10 +88,7 @@ def conns(
|
|
|
70
88
|
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
71
89
|
) -> None:
|
|
72
90
|
"""Manage sing-box connections, requires API token(Optional)"""
|
|
73
|
-
|
|
74
|
-
api_client = create_client(ctx_obj.config, base_url, token)
|
|
75
|
-
manager = ConnectionsManager(api_client)
|
|
76
|
-
asyncio.run(manager.run())
|
|
91
|
+
_run_tui(ctx, lambda client: ConnectionsManager(client).run(), base_url, token)
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
@api.command()
|
|
@@ -81,10 +96,7 @@ def proxy(
|
|
|
81
96
|
ctx: typer.Context, base_url: ApiUrlOption = None, token: ApiTokenOption = None
|
|
82
97
|
) -> None:
|
|
83
98
|
"""Manage sing-box policy groups, requires API token(Optional)"""
|
|
84
|
-
|
|
85
|
-
api_client = create_client(ctx_obj.config, base_url, token)
|
|
86
|
-
manager = PolicyGroupManager(api_client)
|
|
87
|
-
asyncio.run(manager.run())
|
|
99
|
+
_run_tui(ctx, lambda client: PolicyGroupManager(client).run(), base_url, token)
|
|
88
100
|
|
|
89
101
|
|
|
90
102
|
@api.command()
|
|
@@ -95,7 +107,5 @@ def logs(
|
|
|
95
107
|
token: ApiTokenOption = None,
|
|
96
108
|
) -> None:
|
|
97
109
|
"""Show sing-box logs, requires API token(Optional)"""
|
|
98
|
-
ctx_obj = get_context_obj(ctx)
|
|
99
|
-
api_client = create_client(ctx_obj.config, base_url, token)
|
|
100
110
|
print("⌛ Showing real-time logs (Press Ctrl+C to exit)")
|
|
101
|
-
|
|
111
|
+
_run_tui(ctx, lambda client: get_logs(client, log_level), base_url, token)
|
|
@@ -5,6 +5,7 @@ Provides an async client for interacting with Sing-Box HTTP API.
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
from collections.abc import AsyncGenerator
|
|
8
|
+
from contextlib import aclosing
|
|
8
9
|
from typing import Any, TypeVar
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
@@ -224,8 +225,11 @@ class SingBoxAPIClient:
|
|
|
224
225
|
Yields:
|
|
225
226
|
TrafficData object containing traffic data (up/down in B/s)
|
|
226
227
|
"""
|
|
227
|
-
async
|
|
228
|
-
|
|
228
|
+
async with aclosing(
|
|
229
|
+
self._make_stream_request("/traffic", TrafficData)
|
|
230
|
+
) as stream:
|
|
231
|
+
async for data in stream:
|
|
232
|
+
yield data
|
|
229
233
|
|
|
230
234
|
async def memory_stream(self) -> AsyncGenerator[MemoryData, None]:
|
|
231
235
|
"""
|
|
@@ -234,8 +238,9 @@ class SingBoxAPIClient:
|
|
|
234
238
|
Yields:
|
|
235
239
|
MemoryData object containing memory data (inuse/total in bytes)
|
|
236
240
|
"""
|
|
237
|
-
async
|
|
238
|
-
|
|
241
|
+
async with aclosing(self._make_stream_request("/memory", MemoryData)) as stream:
|
|
242
|
+
async for data in stream:
|
|
243
|
+
yield data
|
|
239
244
|
|
|
240
245
|
async def log_stream(
|
|
241
246
|
self, level: str | None = None
|
|
@@ -247,8 +252,11 @@ class SingBoxAPIClient:
|
|
|
247
252
|
LogEntry object containing log data (type/payload)
|
|
248
253
|
"""
|
|
249
254
|
params = {"level": level} if level else None
|
|
250
|
-
async
|
|
251
|
-
|
|
255
|
+
async with aclosing(
|
|
256
|
+
self._make_stream_request("/logs", LogEntry, params=params)
|
|
257
|
+
) as stream:
|
|
258
|
+
async for data in stream:
|
|
259
|
+
yield data
|
|
252
260
|
|
|
253
261
|
async def get_connections(self) -> ConnectionData:
|
|
254
262
|
"""
|
|
@@ -4,6 +4,7 @@ from prompt_toolkit.application import Application
|
|
|
4
4
|
from prompt_toolkit.buffer import Buffer
|
|
5
5
|
from prompt_toolkit.formatted_text import FormattedText
|
|
6
6
|
from prompt_toolkit.key_binding import KeyBindings
|
|
7
|
+
from prompt_toolkit.keys import Keys
|
|
7
8
|
from prompt_toolkit.layout import (
|
|
8
9
|
FormattedTextControl,
|
|
9
10
|
HSplit,
|
|
@@ -168,7 +169,9 @@ class ConnectionsManager:
|
|
|
168
169
|
def setup_keybindings(self) -> None:
|
|
169
170
|
"""Setup keyboard shortcuts."""
|
|
170
171
|
|
|
172
|
+
@self.kb.add("c-c")
|
|
171
173
|
@self.kb.add("c-q")
|
|
174
|
+
@self.kb.add(Keys.SIGINT)
|
|
172
175
|
def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
|
|
173
176
|
"""Quit the application."""
|
|
174
177
|
event.app.exit()
|
|
@@ -176,13 +179,14 @@ class ConnectionsManager:
|
|
|
176
179
|
@self.kb.add("c-d")
|
|
177
180
|
def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
|
|
178
181
|
"""Close the connection."""
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
if self.filtered_connections:
|
|
183
|
+
conn_id = self.selected_connection.id
|
|
184
|
+
event.app.create_background_task(self.close_connection(str(conn_id)))
|
|
181
185
|
|
|
182
|
-
@self.kb.add("c-
|
|
186
|
+
@self.kb.add("c-x", eager=True)
|
|
183
187
|
def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
|
|
184
|
-
"""Close
|
|
185
|
-
|
|
188
|
+
"""Close all connections."""
|
|
189
|
+
event.app.create_background_task(self.close_all_connections())
|
|
186
190
|
|
|
187
191
|
@self.kb.add("down")
|
|
188
192
|
def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
|
|
@@ -301,7 +305,7 @@ class ConnectionsManager:
|
|
|
301
305
|
lambda: [
|
|
302
306
|
(
|
|
303
307
|
"class:status-bar",
|
|
304
|
-
" Ctrl+Q: Quit,
|
|
308
|
+
" Ctrl+C/Ctrl+Q: Quit, Ctrl+D: Close, Ctrl+X: Close All, "
|
|
305
309
|
" ↑/↓: Move, ←/→: Page, "
|
|
306
310
|
" Home/End: First/Last Page, "
|
|
307
311
|
" Input: Search",
|
|
@@ -377,6 +381,7 @@ class ConnectionsManager:
|
|
|
377
381
|
|
|
378
382
|
async def close_connection(self, conn_id: str) -> None:
|
|
379
383
|
"""Close a specific connection."""
|
|
384
|
+
cnnt_info = f"Connection {conn_id=}"
|
|
380
385
|
try:
|
|
381
386
|
await self.api_client.close_connection(conn_id)
|
|
382
387
|
host = self.selected_connection.metadata.host
|
|
@@ -386,7 +391,6 @@ class ConnectionsManager:
|
|
|
386
391
|
await self.refresh_connections()
|
|
387
392
|
except Exception as e:
|
|
388
393
|
self.status_message = f"Error closing {cnnt_info}: {str(e)}"
|
|
389
|
-
raise
|
|
390
394
|
|
|
391
395
|
async def close_all_connections(self) -> None:
|
|
392
396
|
"""Close all connections."""
|
|
@@ -397,7 +401,6 @@ class ConnectionsManager:
|
|
|
397
401
|
await self.refresh_connections()
|
|
398
402
|
except Exception as e:
|
|
399
403
|
self.status_message = f"Error closing all connections: {str(e)}"
|
|
400
|
-
raise
|
|
401
404
|
|
|
402
405
|
async def refresh_connections(self) -> None:
|
|
403
406
|
"""Refresh the connections data."""
|
|
@@ -420,23 +423,21 @@ class ConnectionsManager:
|
|
|
420
423
|
async def run(self) -> None:
|
|
421
424
|
"""Run the interactive manager."""
|
|
422
425
|
self.running = True
|
|
423
|
-
|
|
424
|
-
await self.refresh_connections()
|
|
425
|
-
self.status_message = self.get_filter_state()
|
|
426
|
-
|
|
427
|
-
# Setup periodic refresh task
|
|
428
|
-
refresh_task = asyncio.create_task(self.periodic_refresh())
|
|
429
|
-
|
|
426
|
+
refresh_task = None
|
|
430
427
|
try:
|
|
428
|
+
# Initial load of connections
|
|
429
|
+
await self.refresh_connections()
|
|
430
|
+
self.status_message = self.get_filter_state()
|
|
431
|
+
refresh_task = asyncio.create_task(self.periodic_refresh())
|
|
431
432
|
await self.app.run_async()
|
|
432
433
|
finally:
|
|
433
434
|
self.running = False
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
435
|
+
if refresh_task is not None:
|
|
436
|
+
refresh_task.cancel()
|
|
437
|
+
try:
|
|
438
|
+
await refresh_task
|
|
439
|
+
except asyncio.CancelledError:
|
|
440
|
+
pass
|
|
440
441
|
|
|
441
442
|
async def periodic_refresh(self, interval: float = 0.5) -> None:
|
|
442
443
|
"""Periodically refresh connections."""
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
try:
|
|
39
|
+
async with aclosing(client.log_stream(log_level.value.lower())) as stream:
|
|
40
|
+
async for entry in stream:
|
|
41
|
+
# Get current timestamp with automatic timezone offset
|
|
42
|
+
now = datetime.now().astimezone()
|
|
43
|
+
timestamp = now.strftime("%z %Y-%m-%d %H:%M:%S")
|
|
44
|
+
|
|
45
|
+
# Get level color
|
|
46
|
+
# Start building the output message
|
|
47
|
+
message = Text()
|
|
48
|
+
message.append(timestamp, style="bright_black")
|
|
49
|
+
message.append(" ")
|
|
50
|
+
message.append(
|
|
51
|
+
entry.type.upper(),
|
|
52
|
+
style=log_level_colors.get(entry.type.lower(), "bright_white"),
|
|
53
|
+
)
|
|
54
|
+
message.append(" ")
|
|
55
|
+
|
|
56
|
+
# Process the payload
|
|
57
|
+
payload = entry.payload
|
|
58
|
+
|
|
59
|
+
# Find all ID patterns in the payload
|
|
60
|
+
pattern = r"\[(\d+) (\d+ms)\]"
|
|
61
|
+
last_end = 0
|
|
62
|
+
|
|
63
|
+
for match in re.finditer(pattern, payload):
|
|
64
|
+
# Add text before the match
|
|
65
|
+
message.append(payload[last_end : match.start()])
|
|
66
|
+
|
|
67
|
+
# Extract ID and time part
|
|
68
|
+
log_id = match.group(1)
|
|
69
|
+
time_part = match.group(2)
|
|
70
|
+
|
|
71
|
+
# Assign a consistent color to this ID
|
|
72
|
+
if log_id not in id_color_map:
|
|
73
|
+
color_index = hash(log_id) % len(id_colors)
|
|
74
|
+
id_color_map[log_id] = id_colors[color_index]
|
|
75
|
+
|
|
76
|
+
id_color = id_color_map[log_id]
|
|
77
|
+
|
|
78
|
+
# Add the ID with coloring
|
|
79
|
+
message.append("[", style="default")
|
|
80
|
+
message.append(log_id, style=id_color)
|
|
81
|
+
message.append(f" {time_part}]", style="default")
|
|
82
|
+
|
|
83
|
+
last_end = match.end()
|
|
84
|
+
|
|
85
|
+
# Add any remaining text
|
|
86
|
+
if last_end < len(payload):
|
|
87
|
+
message.append(payload[last_end:])
|
|
88
|
+
|
|
89
|
+
# Print the complete message
|
|
90
|
+
print(message)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"[red]Error:[/red] {e}")
|
|
93
|
+
raise
|