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.
Files changed (37) hide show
  1. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/PKG-INFO +40 -3
  2. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/README.md +39 -2
  3. sing_box_cli-0.1.3/docs/config-serve.md +49 -0
  4. sing_box_cli-0.1.3/docs/traefik-config-serve.yml +19 -0
  5. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/pyproject.toml +2 -2
  6. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/__init__.py +1 -1
  7. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/__init__.py +27 -17
  8. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/client.py +14 -6
  9. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/connections.py +22 -21
  10. sing_box_cli-0.1.3/src/sing_box_cli/api/logs.py +93 -0
  11. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/monitor.py +45 -56
  12. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/policy.py +18 -16
  13. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/__init__.py +12 -1
  14. sing_box_cli-0.1.3/src/sing_box_cli/config/_serve_worker.py +56 -0
  15. sing_box_cli-0.1.3/src/sing_box_cli/config/serve.py +282 -0
  16. sing_box_cli-0.1.3/src/sing_box_cli/config/serve_backend.py +340 -0
  17. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/utils.py +3 -1
  18. sing_box_cli-0.1.3/src/sing_box_cli/installation.py +67 -0
  19. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/main.py +15 -0
  20. sing_box_cli-0.1.2/src/sing_box_cli/api/logs.py +0 -93
  21. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/.gitignore +0 -0
  22. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/LICENSE +0 -0
  23. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/hatch_build.py +0 -0
  24. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/api/widget.py +0 -0
  25. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/common.py +0 -0
  26. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/config/config.py +0 -0
  27. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/py.typed +0 -0
  28. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/service/__init__.py +0 -0
  29. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/src/sing_box_cli/service/manager.py +0 -0
  30. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/common.go +0 -0
  31. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/common_test.go +0 -0
  32. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/go.mod +0 -0
  33. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/go.sum +0 -0
  34. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/integration_windows_test.go +0 -0
  35. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/main_other.go +0 -0
  36. {sing_box_cli-0.1.2 → sing_box_cli-0.1.3}/windows-service/main_windows.go +0 -0
  37. {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.2
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
- sudo ln -sf $(which sing-box-cli) /usr/local/bin/
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
- sudo ln -sf $(which sing-box-cli) /usr/local/bin/
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.2"
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}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.3"
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
- from typing import Annotated
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
- ctx_obj = get_context_obj(ctx)
62
- api_client = create_client(ctx_obj.config, base_url, token)
63
- visualizer = ResourceVisualizer()
64
- monitor = ResourceMonitor(api_client, visualizer)
65
- asyncio.run(monitor.start())
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
- ctx_obj = get_context_obj(ctx)
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
- ctx_obj = get_context_obj(ctx)
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
- asyncio.run(get_logs(api_client, log_level))
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 for data in self._make_stream_request("/traffic", TrafficData):
228
- yield data
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 for data in self._make_stream_request("/memory", MemoryData):
238
- yield data
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 for data in self._make_stream_request("/logs", LogEntry, params=params):
251
- yield data
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
- conn_id = self.selected_connection.id
180
- asyncio.create_task(self.close_connection(str(conn_id)))
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-c")
186
+ @self.kb.add("c-x", eager=True)
183
187
  def _(event) -> None: # type: ignore[no-untyped-def] # noqa: ARG001
184
- """Close the connection."""
185
- asyncio.create_task(self.close_all_connections())
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, Ctrl+D: Close, Ctrl+C: Close All, "
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
- # Initial load of connections
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
- # Clean up periodic refresh
435
- refresh_task.cancel()
436
- try:
437
- await refresh_task
438
- except asyncio.CancelledError:
439
- pass
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