sing-box-cli 0.1.6__tar.gz → 0.1.8__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 (38) hide show
  1. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/PKG-INFO +1 -1
  2. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/docs/config-serve.md +3 -3
  3. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/pyproject.toml +1 -1
  4. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/__init__.py +1 -1
  5. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/__init__.py +8 -1
  6. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/client.py +11 -2
  7. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/connections.py +9 -1
  8. sing_box_cli-0.1.8/src/sing_box_cli/api/logs.py +89 -0
  9. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/monitor.py +56 -22
  10. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve.py +3 -41
  11. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/main.py +2 -2
  12. sing_box_cli-0.1.6/src/sing_box_cli/api/logs.py +0 -93
  13. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/.gitignore +0 -0
  14. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/LICENSE +0 -0
  15. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/README.md +0 -0
  16. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/docs/traefik-config-serve.yml +0 -0
  17. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/hatch_build.py +0 -0
  18. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/policy.py +0 -0
  19. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/api/widget.py +0 -0
  20. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/common.py +0 -0
  21. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/__init__.py +0 -0
  22. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/_serve_worker.py +0 -0
  23. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/config.py +0 -0
  24. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve_backend.py +0 -0
  25. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/serve_tokens.py +0 -0
  26. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/config/utils.py +0 -0
  27. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/installation.py +0 -0
  28. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/py.typed +0 -0
  29. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/service/__init__.py +0 -0
  30. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/src/sing_box_cli/service/manager.py +0 -0
  31. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/common.go +0 -0
  32. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/common_test.go +0 -0
  33. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/go.mod +0 -0
  34. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/go.sum +0 -0
  35. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/integration_windows_test.go +0 -0
  36. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/main_other.go +0 -0
  37. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/windows-service/main_windows.go +0 -0
  38. {sing_box_cli-0.1.6 → sing_box_cli-0.1.8}/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.6
3
+ Version: 0.1.8
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
@@ -33,9 +33,9 @@ every token for that file without affecting other files. Multiple tokens may
33
33
  authorize the same file, so rotation can create a replacement before revoking the
34
34
  old token. Revocation never prints the secret.
35
35
 
36
- The longer `config serve token -l/-c/-r` forms remain supported. The previous
37
- `config serve token create/list/revoke` subcommands remain hidden compatibility
38
- aliases; the legacy `revoke NAME` form still accepts a token name.
36
+ Token management uses only the top-level `sbc token` options. The previous
37
+ `config serve token` entry point and `token create/list/revoke` subcommands have
38
+ been removed.
39
39
 
40
40
  Clients can send the token as a Bearer header using `-t` / `--token`, or use the
41
41
  printed URL with `?token=...` in applications that cannot set headers:
@@ -215,7 +215,7 @@ topo_order = false
215
215
  sort_commits = "oldest"
216
216
 
217
217
  [tool.bumpversion]
218
- current_version = "0.1.6"
218
+ current_version = "0.1.8"
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.6"
5
+ __version__ = "0.1.8"
@@ -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 ValueError(f"Invalid to initialize client: {self.base_url}")
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=None
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 ConnectionInfo, MemoryData, SingBoxAPIClient, TrafficData
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, traffic_data: TrafficData, memory_data: MemoryData
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
- layout["footer"].update(Text("Press Ctrl+C to exit", justify="center"))
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
- try:
649
- async with aclosing(self.api_client.traffic_stream()) as stream:
650
- async for traffic_data in stream:
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
- try:
661
- async with aclosing(self.api_client.memory_stream()) as stream:
662
- async for memory_data in stream:
663
- self.current_memory = memory_data
664
- if not self.running:
665
- break
666
- await asyncio.sleep(self.task_interval)
667
- except Exception as e:
668
- self.visualizer.console.print(f"[red]Error monitoring memory: {str(e)}")
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
 
@@ -25,17 +25,10 @@ from .serve_backend import (
25
25
  state_directory,
26
26
  validate_config,
27
27
  )
28
- from .serve_tokens import (
29
- create_token,
30
- load_tokens,
31
- revoke_subscription_tokens,
32
- revoke_token,
33
- )
28
+ from .serve_tokens import create_token, load_tokens, revoke_subscription_tokens
34
29
 
35
30
  WORKER_MODULE = "sing_box_cli.config._serve_worker"
36
31
  serve = typer.Typer(help="Serve private GitHub configurations through your own domain.")
37
- tokens = typer.Typer(help="Create, list and revoke independent subscription tokens.")
38
- serve.add_typer(tokens, name="token")
39
32
 
40
33
 
41
34
  class ProcessState(BaseModel):
@@ -278,7 +271,6 @@ def start(
278
271
  print(f"Log: {directory / 'serve.log'}")
279
272
 
280
273
 
281
- @tokens.callback(invoke_without_command=True)
282
274
  def token_options(
283
275
  ctx: typer.Context,
284
276
  list_: Annotated[
@@ -310,12 +302,8 @@ def token_options(
310
302
  str | None, typer.Option("--name", "-n", help="Optional name for a new token.")
311
303
  ] = None,
312
304
  ) -> None:
313
- """Manage subscription tokens with -l, -c FILE or -r TOKEN_OR_FILE."""
305
+ """Create, list and revoke independent subscription tokens."""
314
306
  actions = int(list_) + int(create is not None) + int(revoke is not None)
315
- if ctx.invoked_subcommand is not None:
316
- if actions or domain is not None or name is not None:
317
- raise typer.BadParameter("Use options or a legacy subcommand, not both.")
318
- return
319
307
  if actions == 0 and domain is None and name is None:
320
308
  print(ctx.get_help())
321
309
  return
@@ -348,18 +336,7 @@ def token_options(
348
336
  print(f"Revoked {count} subscription token(s).")
349
337
 
350
338
 
351
- @tokens.command("create", hidden=True)
352
- def token_create(
353
- name: Annotated[
354
- str, typer.Argument(help="Unique name for this subscription token.")
355
- ],
356
- domain: Annotated[
357
- str, typer.Option("--domain", help="Registered subscription domain.")
358
- ],
359
- filename: Annotated[
360
- str, typer.Option("--file", help="Root configuration filename.")
361
- ],
362
- ) -> None:
339
+ def token_create(name: str, domain: str, filename: str) -> None:
363
340
  """Generate a file-scoped token and print its secret and URL once."""
364
341
  with command_errors():
365
342
  domain = normalize_domain(domain)
@@ -379,7 +356,6 @@ def token_create(
379
356
  print("Save the token now; only its hash is stored on the server.")
380
357
 
381
358
 
382
- @tokens.command("list", hidden=True)
383
359
  def token_list() -> None:
384
360
  """List token names and scopes without displaying their secrets."""
385
361
  with command_errors():
@@ -390,20 +366,6 @@ def token_list() -> None:
390
366
  print(f"{item.name}\t{item.domain}\t{item.filename}\t{item.created_at}")
391
367
 
392
368
 
393
- @tokens.command("revoke", hidden=True)
394
- def token_revoke(
395
- name: Annotated[
396
- str, typer.Argument(help="Name of the subscription token to revoke.")
397
- ],
398
- ) -> None:
399
- """Revoke a token; the next request is denied without a service restart."""
400
- with command_errors():
401
- directory = state_directory()
402
- with control_lock(directory):
403
- revoke_token(directory, name)
404
- print(f"Revoked subscription token: {name}")
405
-
406
-
407
369
  @serve.command("stop")
408
370
  def stop() -> None:
409
371
  """Stop this user's background configuration service."""
@@ -9,7 +9,7 @@ from sing_box_cli.api import api as api_app
9
9
  from sing_box_cli.common import ClearCacheOption, UpdateConfigOption, ensure_root
10
10
  from sing_box_cli.config import config as config_app
11
11
  from sing_box_cli.config.config import get_config, run_args
12
- from sing_box_cli.config.serve import tokens as token_app
12
+ from sing_box_cli.config.serve import token_options
13
13
  from sing_box_cli.installation import install_launchers
14
14
  from sing_box_cli.service import SharedContext, get_context_obj, service as service_app
15
15
  from sing_box_cli.service.manager import create_service
@@ -18,7 +18,7 @@ app = typer.Typer(help="sing-box manager.")
18
18
  app.add_typer(api_app)
19
19
  app.add_typer(service_app, name="service")
20
20
  app.add_typer(config_app, name="config")
21
- app.add_typer(token_app, name="token")
21
+ app.command("token")(token_options)
22
22
 
23
23
 
24
24
  @app.callback(invoke_without_command=False)
@@ -1,93 +0,0 @@
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
File without changes
File without changes
File without changes